предисловие
В этой части есть три статьи, в основном объясняющие содержание планировщика go.
Три статьи:
- Поймите одно из планирования Golang: планирование операционной системы
- Понимание планирования Golang, часть 2: Планировщик Go
- Понимание третьего планирования Golang: параллелизм
Введение
Когда я решаю проблему, особенно новую, я не начинаю думать о том, подходит ли параллелизм. Сначала я бы выбрал ряд решений и убедился, что они работают. Затем, после оценки удобочитаемости и технического решения, я бы начал думать, действительно ли параллелизм разумен. Иногда преимущества параллелизма очевидны, а иногда нет.
первая статья, я объяснил про планировщик ОС, я думаю, что эта часть очень важна для вас, чтобы писать многопоточный код.во-вторых, я объяснил некоторые вещи о планировщике Go, эта часть очень полезна для понимания и написания параллельного кода в go. В этой статье я дам вам глубокое понимание того, что такое параллелизм на уровне ОС и планировщика Go.
Цели этого раздела заключаются в том, чтобы:
- Уместно ли использовать параллелизм для ваших рабочих нагрузок? Предоставьте некоторые рекомендации по этому поводу.
- Значение различных рабочих нагрузок и принятие инженерных решений по ним.
что такое параллелизм
Параллелизм означаетвнеочередное исполнение. Учитывая последовательность инструкций, найдите способ выполнить их не по порядку и получить тот же результат, что и при выполнении по порядку. Проблема перед вами, очевидно, что неупорядоченное выполнение добавит достаточного прироста производительности после вычисления стоимости сложности, но вы можете почувствовать, что неупорядоченное выполнение невозможно или даже бессмысленно.
Вы также должны быть четкими,Параллелизм и параллелизм — не одно и то же. Параллелизм — это одновременное выполнение двух или более инструкций одновременно, что отличается от концепции параллелизма.
Рисунок 3.1
На рис. 3.1 вы видите, что на хосте есть два логических процессора. У каждого есть отдельный поток ОС (M), прикрепленный к отдельному аппаратному потоку (Core). Вы можете видеть, что 2 горутины (G1 и G2) одновременно выполняют свои инструкции в соответствующих потоках ОС/аппаратных средств. В каждом логическом процессоре есть 3 горутины, которые циклически распределяют потоки ОС. Эти горутины одновременно выполняют свои инструкции не по порядку и разделяют временные интервалы между потоками ОС.
Здесь есть проблема. Иногда использование параллелизма без параллелизма на самом деле снижает пропускную способность, и, что интересно, иногда использование параллелизма в сочетании с параллелизмом не дает желаемого прироста производительности.
рабочие нагрузки
Откуда вы знаете, что возможно неупорядоченное выполнение (параллелизм)? Понимание рабочей нагрузки проблемы, над которой вы работаете, является отправной точкой. Существует два типа рабочих нагрузок, которые следует учитывать при параллелизме.
- Связанный с процессором: в этой ситуации рабочей нагрузки горутины не будут автоматически переключаться в состояние ожидания, а также не будут автоматически переключаться из состояния ожидания в другие состояния. Это происходит, когда выполняются непрерывные вычисления. Расчет потока значения Pi зависит от процессора.
- IO-привязка (IO-привязка): эта рабочая нагрузка заставляет горутины автоматически переходить в состояние ожидания. Такого рода работа возникает при постоянном запросе сетевых ресурсов, совершении системных вызовов или ожидании возникновения событий. Горутина, которой нужно прочитать файл, привязана к IO. Я группирую синхронные события (мьютексы, атомарные), такие как ситуации, которые заставляют горутины ждать в этой категории.
Для рабочих нагрузок, связанных с процессором, вам необходимо использовать параллелизм параллельно. Один поток ОС/аппаратного обеспечения, обрабатывающий несколько горутин, неэффективен, потому что в этой рабочей нагрузке горутины не входят в состояние ожидания и не выходят из него. Когда количество горутин больше, чем количество потоков ОС/аппаратных средств, скорость выполнения рабочей нагрузки будет замедлена, поскольку возникнет задержка (время переключения) при замене или удалении горутин из потоков ОС. Переключение контекста создаст в рабочей нагрузке событие «все останавливается», потому что ни одна из ваших рабочих нагрузок не будет выполняться во время переключения.
В рабочих нагрузках с привязкой к вводу-выводу параллелизм не нужен для использования параллелизма. Один поток ОС/аппаратного обеспечения может эффективно обрабатывать несколько горутин, потому что они могут автоматически входить в состояние ожидания и выходить из него в рамках своей собственной рабочей нагрузки. Наличие большего количества горутин, чем потоков ОС/аппаратных средств, может ускорить выполнение рабочей нагрузки, поскольку переключение между горутинами в потоках ОС не создает события «все останавливается». Ваша рабочая нагрузка остановится естественным образом, и это позволит другой Goroutine эффективно использовать один и тот же поток ОС/аппаратного обеспечения, а не простоять потока ОС/аппаратного обеспечения.
Откуда вы знаете, сколько горутин на аппаратный поток будет иметь наилучшую пропускную способность? Слишком мало Горутин, и у вас будет больше свободного времени. Слишком много Горутин, и у вас больше задержки переключения контекста. Это то, о чем вам нужно подумать, но это выходит за рамки Объем этой статьи.
Теперь нам нужно взглянуть на некоторый код, чтобы укрепить ваше суждение о том, когда рабочая нагрузка может использовать преимущества параллелизма, когда ей нужно использовать преимущества параллелизма, а когда нет.
Целочисленное накопление
Не нужно слишком сложного кода, просто посмотрите на следующееadd
функция. Он вычисляет сумму множества целых чисел.
L1
36 func add(numbers []int) int {
37 var v int
38 for _, n := range numbers {
39 v += n
40 }
41 return v
42 }
В строке 36 L1 объявляется метод add, который берет срез int и возвращает их сумму. 37 определяет переменную v для цифрового накопления. В строке 38 функция перебирает целые числа, а в строке 39 добавляется текущее число. Последние 41 строка возвращает свою сумму.
Question: add
Подходит ли он для внеочередного исполнения? Я считаю, что ответ определенно да. Наборы целых чисел можно разбить на списки меньшего размера, и эти списки можно обрабатывать параллельно. Как только все списки добавлены по отдельности, сумма списка списков может быть сложена вместе, чтобы получить тот же результат, что и в приведенном выше коде.
Однако пришла другая проблема. Сколько списков мы должны разделить и обработать отдельно, чтобы получить наилучшую пропускную способность? Чтобы ответить на этот вопрос, нужно знатьadd
На какой рабочей нагрузке работает метод.add
Этот метод имеет дело с рабочей нагрузкой, связанной с ЦП, поскольку это чисто математический метод, и он не приводит к автоматическому переходу горутин в состояние ожидания. Это означает, что одна горутина на каждый поток ОС/аппаратного обеспечения может обеспечить идеальную пропускную способность.
L2 нижеadd
Параллельная версия метода.
Примечание. У вас есть несколько способов написания параллельных версий add без необходимости возиться с самим кодом.
44 func addConcurrent(goroutines int, numbers []int) int {
45 var v int64
46 totalNumbers := len(numbers)
47 lastGoroutine := goroutines - 1
48 stride := totalNumbers / goroutines
49
50 var wg sync.WaitGroup
51 wg.Add(goroutines)
52
53 for g := 0; g < goroutines; g++ {
54 go func(g int) {
55 start := g * stride
56 end := start + stride
57 if g == lastGoroutine {
58 end = totalNumbers
59 }
60
61 var lv int
62 for _, n := range numbers[start:end] {
63 lv += n
64 }
65
66 atomic.AddInt64(&v, int64(lv))
67 wg.Done()
68 }(g)
69 }
70
71 wg.Wait()
72
73 return int(v)
74 }
Внутри L2,addConcurrent
путьadd
Параллельная версия метода. Здесь много кода, поэтому я расскажу только о важных строках кода.
Line 48: Каждая горутина будет иметь свой собственный небольшой список для обработки. Размер списка получается путем деления размера целочисленного набора на количество горутин.
Line 53: Создайте пул потоков горутин для обработки операций сложения.
Line 57-59: последние горутины будут обрабатывать последний оставшийся список, который может быть больше, чем размер других списков.
Line 66: Сумма, рассчитанная по всем спискам, суммируется, чтобы получить последнюю сумму.
Параллельная версия сложнее заказанной версии, стоит ли она того? Лучший способ ответить на этот вопрос — написать тест. Здесь я использовал набор из 10 миллионов целых чисел и отключил сборку мусора. Прямо здесьadd
а такжеaddConcurrent
Было проведено сравнение.
L3
func BenchmarkSequential(b *testing.B) {
for i := 0; i < b.N; i++ {
add(numbers)
}
}
func BenchmarkConcurrent(b *testing.B) {
for i := 0; i < b.N; i++ {
addConcurrent(runtime.NumCPU(), numbers)
}
}
L3 показывает эталонную функцию. Вот что происходит, когда горутины имеют только один доступный поток ОС/аппаратного обеспечения. Упорядоченная версия использует 1 горутин, тогда как параллельная версия используетruntime.NumCPU
Номер 8 на моей машине. Ниже этого примера параллельная версия не использует параллелизм для параллелизма.
L4
10 Million Numbers using 8 goroutines with 1 core
2.9 GHz Intel 4 Core i7
Concurrency WITHOUT Parallelism
-----------------------------------------------------------------------------
$ GOGC=off go test -cpu 1 -run none -bench . -benchtime 3s
goos: darwin
goarch: amd64
pkg: github.com/ardanlabs/gotraining/topics/go/testing/benchmarks/cpu-bound
BenchmarkSequential 1000 5720764 ns/op : ~10% Faster
BenchmarkConcurrent 1000 6387344 ns/op
BenchmarkSequentialAgain 1000 5614666 ns/op : ~13% Faster
BenchmarkConcurrentAgain 1000 6482612 ns/op
Примечание. Запуск BenchMark на вашем компьютере сложен. Есть много факторов, которые могут привести к тому, что ваши тесты будут неточными. Старайтесь, чтобы ваша машина простаивала, насколько это возможно, чтобы вы могли какое-то время запускать тесты, чтобы убедиться, что вы видите результаты, которые примерно такие же, как выше. Двойной запуск эталонного теста с тестовой программой дает более стабильные результаты.
Тесты, предоставленные L4, показывают, что упорядоченная версия примерно на 10–% 13 быстрее, чем параллельная версия, когда имеется только один поток ОС/аппаратного обеспечения. Это ожидаемо, поскольку параллельная версия требует частых переключений контекста и обработки горутин в одном потоке ОС.
Ниже показан результат для случая, когда каждая горутина имеет отдельный поток ОС/аппаратного обеспечения. Упорядоченная версия использует Goroutine, тогда как параллельная версия используетruntime.NumCPU
, 8 на моей локальной машине. В этом случае параллелизм используется для обработки параллелизма.
L5
10 Million Numbers using 8 goroutines with 8 cores
2.9 GHz Intel 4 Core i7
Concurrency WITH Parallelism
-----------------------------------------------------------------------------
$ GOGC=off go test -cpu 8 -run none -bench . -benchtime 3s
goos: darwin
goarch: amd64
pkg: github.com/ardanlabs/gotraining/topics/go/testing/benchmarks/cpu-bound
BenchmarkSequential-8 1000 5910799 ns/op
BenchmarkConcurrent-8 2000 3362643 ns/op : ~43% Faster
BenchmarkSequentialAgain-8 1000 5933444 ns/op
BenchmarkConcurrentAgain-8 2000 3477253 ns/op : ~41% Faster
Тесты в L5 показывают, что параллельная версия примерно на 41–43% быстрее, чем упорядоченная версия, когда каждая горутина использует один поток ОС/аппаратного обеспечения. Это то, что мы ожидали, так как все горутины теперь выполняются параллельно, и все 8 горутин теперь выполняются одновременно.
Сортировать
Важно понимать, что не все рабочие нагрузки, привязанные к ЦП, подходят для параллельной обработки. Это верно, когда дорого разделить работу или объединить результаты. В этом случае мы можем посмотреть на пример алгоритма: пузырьковая сортировка. Взгляните на пузырьковую сортировку, реализованную в Go.
L6
01 package main
02
03 import "fmt"
04
05 func bubbleSort(numbers []int) {
06 n := len(numbers)
07 for i := 0; i < n; i++ {
08 if !sweep(numbers, i) {
09 return
10 }
11 }
12 }
13
14 func sweep(numbers []int, currentPass int) bool {
15 var idx int
16 idxNext := idx + 1
17 n := len(numbers)
18 var swap bool
19
20 for idxNext < (n - currentPass) {
21 a := numbers[idx]
22 b := numbers[idxNext]
23 if a > b {
24 numbers[idx] = b
25 numbers[idxNext] = a
26 swap = true
27 }
28 idx++
29 idxNext = idx + 1
30 }
31 return swap
32 }
33
34 func main() {
35 org := []int{1, 3, 2, 4, 8, 6, 7, 2, 3, 0}
36 fmt.Println(org)
37
38 bubbleSort(org)
39 fmt.Println(org)
40 }
В L6 дается Go-версия пузырьковой сортировки. Алгоритм сортировки перебирает каждое значение и чередует данные в наборе целых чисел. Сортировка может потребовать нескольких проходов, в зависимости от первоначального порядка.
Question: bubbleSort
Подходит ли рабочая нагрузка для внеочередного выполнения? Ответ определенно нет. Наборы целых чисел можно разбить на меньшие списки, и эти списки можно сортировать одновременно. Но после того, как вся параллельная работа выполнена, нет эффективного способа отсортировать эти небольшие списки вместе. Вот параллельная версия пузырьковой сортировки.
L8
01 func bubbleSortConcurrent(goroutines int, numbers []int) {
02 totalNumbers := len(numbers)
03 lastGoroutine := goroutines - 1
04 stride := totalNumbers / goroutines
05
06 var wg sync.WaitGroup
07 wg.Add(goroutines)
08
09 for g := 0; g < goroutines; g++ {
10 go func(g int) {
11 start := g * stride
12 end := start + stride
13 if g == lastGoroutine {
14 end = totalNumbers
15 }
16
17 bubbleSort(numbers[start:end])
18 wg.Done()
19 }(g)
20 }
21
22 wg.Wait()
23
24 // Ugh, we have to sort the entire list again.
25 bubbleSort(numbers)
26 }
Л8,bubbleSortConcurrent
путьbubbleSort
параллельная версия. Он использует несколько горутин для одновременной сортировки частей всего набора целых чисел. В результате вы получите соответствующий отсортированный список. В результате вы закончите сортировку всего списка в строке 25.
Потому что суть пузырьковой сортировки заключается в обходе всего списка. 25 звонков по линииbubbleSort
Напрямую сводит на нет любые потенциальные преимущества параллелизма. В пузырьковой сортировке использование параллелизма не дает прироста производительности.
прочитать файл
Мы задали 2 рабочие нагрузки типа с привязкой к процессору, так какова ситуация с рабочей нагрузкой типа с привязкой к вводу-выводу? Есть ли какая-то разница, когда горутины автоматически входят в состояние ожидания или выходят из него? Рассмотрим рабочую нагрузку, связанную с вводом-выводом, которая читает файл и ищет текст.
Первая версия является упорядоченной версиейfind
метод
L10
42 func find(topic string, docs []string) int {
43 var found int
44 for _, doc := range docs {
45 items, err := read(doc)
46 if err != nil {
47 continue
48 }
49 for _, item := range items {
50 if strings.Contains(item.Description, topic) {
51 found++
52 }
53 }
54 }
55 return found
56 }
Внутри L10 вы видите упорядоченную версиюfind
функция. строка 43 определяетfound
переменная для сохраненияtopic
Количество вхождений в документе. строка 44, перебрать все документы, а в строке 45 использоватьread
метод для чтения каждого документа. Наконец, в строках 49–53 используйтеstrings
упаковкаContains
способ проверить, находится ли тема в прочитанных элементах. Если найдено,found
Переменная увеличивается на единицу.
вотfind
называетсяread
реализация метода.
L11
33 func read(doc string) ([]item, error) {
34 time.Sleep(time.Millisecond) // Simulate blocking disk read.
35 var d document
36 if err := xml.Unmarshal([]byte(file), &d); err != nil {
37 return nil, err
38 }
39 return d.Channel.Items, nil
40 }
read
метод сtime.Sleep
метод начинается. Это имитирует задержку, вызванную реальным системным вызовом для чтения документа с диска. Установка этой задержки позволяет нам точно тестировать упорядоченные и параллельные версии.find
Разница в эффективности методов значительна. Затем в строках 35–39 тестовый XML-документ сохраняется вfine
, он десериализуется в структуру для обработки. Наконец, возвращается коллекция элементов.
Ниже приведен код параллельной версии.
Примечание. Существует несколько способов написания параллельных версий кода, не зацикливайтесь на реализации самого кода.
L12
58 func findConcurrent(goroutines int, topic string, docs []string) int {
59 var found int64
60
61 ch := make(chan string, len(docs))
62 for _, doc := range docs {
63 ch <- doc
64 }
65 close(ch)
66
67 var wg sync.WaitGroup
68 wg.Add(goroutines)
69
70 for g := 0; g < goroutines; g++ {
71 go func() {
72 var lFound int64
73 for doc := range ch {
74 items, err := read(doc)
75 if err != nil {
76 continue
77 }
78 for _, item := range items {
79 if strings.Contains(item.Description, topic) {
80 lFound++
81 }
82 }
83 }
84 atomic.AddInt64(&found, lFound)
85 wg.Done()
86 }()
87 }
88
89 wg.Wait()
90
91 return int(found)
92 }
L12 естьfind
Параллельная версия метода. Параллельная версия содержит 30 строк кода, а непараллельная версия — всего 13 строк кода. Моя цель — контролировать количество горутин при обработке неизвестного количества документов. Здесь я выбираю использование канала в режиме пула для передачи данных горутинам в пуле.
Эта часть кода больше, я объясняю только важную часть
Line 61-64: Создайте канал для обработки всех документов.Line 65Закройте этот канал, чтобы горутины в пуле автоматически останавливались после обработки всех документов.
Line 70: создать пул потоков горутин
Line 73--83: Каждая горутина в пуле принимает документ из канала, считывает его в память и проверяет, есть ли у контента тема. Если есть совпадение, lfound добавит его.
Line 84: сложите счетчики, запущенные каждой отдельной горутиной.
Параллельная версия действительно сложнее, чем упорядоченный код версии, стоит ли сложность того? Лучший способ проверить это снова написать бенчмарк. Я использовал коллекцию из 1000 документов с отключенной сборкой мусора. Одна из них — последовательная версия.find
, одна из них является параллельной версиейfindConcurrent
L13
func BenchmarkSequential(b *testing.B) {
for i := 0; i < b.N; i++ {
find("test", docs)
}
}
func BenchmarkConcurrent(b *testing.B) {
for i := 0; i < b.N; i++ {
findConcurrent(runtime.NumCPU(), "test", docs)
}
}
L13 дает ориентир. Ниже показано, когда все горутины имеют только один поток ОС/аппаратного обеспечения. В последовательном коде используется 1 горутина, а в параллельной версииruntime.NumCPU
У меня на компе цифра 8. В этом случае мы не используем параллелизм для параллелизма.
L14
10 Thousand Documents using 8 goroutines with 1 core
2.9 GHz Intel 4 Core i7
Concurrency WITHOUT Parallelism
-----------------------------------------------------------------------------
$ GOGC=off go test -cpu 1 -run none -bench . -benchtime 3s
goos: darwin
goarch: amd64
pkg: github.com/ardanlabs/gotraining/topics/go/testing/benchmarks/io-bound
BenchmarkSequential 3 1483458120 ns/op
BenchmarkConcurrent 20 188941855 ns/op : ~87% Faster
BenchmarkSequentialAgain 2 1502682536 ns/op
BenchmarkConcurrentAgain 20 184037843 ns/op : ~88% Faster
В L14 показано, что при наличии только одного потока ОС/аппаратного обеспечения параллельная версия примерно на 87–88% быстрее, чем код последовательной версии. Это ожидаемо, потому что каждая горутина может эффективно использовать один поток ОС/аппаратного обеспечения. существуетread
Каждая горутина может автоматически переключать контекст при вызове, так что потоку ОС/аппаратного обеспечения всегда будет чем заняться.
Ниже показано использование параллелизма для параллельной обработки.
L15
10 Thousand Documents using 8 goroutines with 1 core
2.9 GHz Intel 4 Core i7
Concurrency WITH Parallelism
-----------------------------------------------------------------------------
$ GOGC=off go test -run none -bench . -benchtime 3s
goos: darwin
goarch: amd64
pkg: github.com/ardanlabs/gotraining/topics/go/testing/benchmarks/io-bound
BenchmarkSequential-8 3 1490947198 ns/op
BenchmarkConcurrent-8 20 187382200 ns/op : ~88% Faster
BenchmarkSequentialAgain-8 3 1416126029 ns/op
BenchmarkConcurrentAgain-8 20 185965460 ns/op : ~87% Faster
Результаты тестов L15 показывают, что дополнительные потоки ОС/оборудования не обеспечивают лучшей производительности.
В заключение
Цель этой статьи — сообщить вам, когда ваша рабочая нагрузка подходит для использования параллелизма. Рассматривая разные сценарии, я привел разные примеры.
Вы можете ясно видеть, что рабочим нагрузкам типа IO-Bound не нужно использовать параллельную обработку для получения значительного увеличения производительности, что прямо противоположно рабочим нагрузкам типа CPU-Bound. Подобно всплывающим алгоритмам, использование параллелизма на самом деле увеличивает сложность кода без какого-либо прироста производительности. Поэтому важно определить, подходит ли ваша рабочая нагрузка для использования в параллельных сценариях.