Go
27 September 2013

Go: многопоточность и параллельность

Люблю Go, люблю его хвалить (бывает даже, привираю слега), люблю о нем статьи. Прочитал статью “Go: Два года в продакшне”, потом комменты. Стало понятно, на хабре — оптимисты! Хотят верить в лучшее.

По умолчанию Go работает на одном потоке, используя свой шедулер и асинхронные вызовы. (У программиста создается ощущение многопоточности и параллельности.) В этом случае каналы работаю очень быстро. Но если указать Go использовать 2 и больше потока, то Go начинает использовать блокировки и производительность каналов может падать. Не хочется себя ограничивать в использовании каналов. Тем более, большинство сторонних библиотек при каждом удобном случае используют каналы. Поэтому часто эффективно запускать Go с одним потоком, как это сделано по умолчанию.

channel01.go
package main

import "fmt"
import "time"
import "runtime"

func main() {
    
    numcpu := runtime.NumCPU()
    fmt.Println("NumCPU", numcpu)
    //runtime.GOMAXPROCS(numcpu)
    runtime.GOMAXPROCS(1)
    
	ch1 := make(chan int)
	ch2 := make(chan float64)

	go func() {
		for i := 0; i < 1000000; i++ {
			ch1 <- i
		}
		ch1 <- -1
		ch2 <- 0.0
	}()
	go func() {
          total := 0.0
		for {
			t1 := time.Now().UnixNano()
			for i := 0; i < 100000; i++ {
				m := <-ch1
				if m == -1 {
					ch2 <- total
				}
			}
			t2 := time.Now().UnixNano()
			dt := float64(t2 - t1) / 1000000.0
			total += dt
			fmt.Println(dt)
		}
	}()
	
	fmt.Println("Total:", <-ch2, <-ch2)
}



users-iMac:channel user$ go run channel01.go 
NumCPU 4
23.901
24.189
23.957
24.072
24.001
23.807
24.039
23.854
23.798
24.1
Total: 239.718 0


теперь давайте активируем все ядра, перекомментировав строки.

    runtime.GOMAXPROCS(numcpu)
    //runtime.GOMAXPROCS(1)


users-iMac:channel user$ go run channel01.go 
NumCPU 4
543.092
534.985
535.799
533.039
538.806
533.315
536.501
533.261
537.73
532.585
Total: 5359.113 0


20 раз медленней? В чем подвох? Размер канала по умолчанию 1.

	ch1 := make(chan int)


Поставим 100.

	ch1 := make(chan int, 100)


результат 1 поток
users-iMac:channel user$ go run channel01.go 
NumCPU 4
9.704
9.618
9.178
9.84
9.869
9.461
9.802
9.743
9.877
9.756
Total: 0 96.848


результат 4 потока
users-iMac:channel user$ go run channel01.go 
NumCPU 4
17.046
17.046
16.71
16.315
16.542
16.643
17.69
16.387
17.162
15.232
Total: 0 166.77300000000002


Всего в два раза медленней, но не всегда можно это использовать.

Пример “канал каналов”


package main

import "fmt"
import "time"
import "runtime"

func main() {
    
    numcpu := runtime.NumCPU()
    fmt.Println("NumCPU", numcpu)
    //runtime.GOMAXPROCS(numcpu)
    runtime.GOMAXPROCS(1)
    
	ch1 := make(chan chan int, 100)
	ch2 := make(chan float64, 1)

	go func() {
		t1 := time.Now().UnixNano()
		for i := 0; i < 1000000; i++ {
      		ch := make(chan int, 100)
			ch1 <- ch
			<- ch
		}
		t2 := time.Now().UnixNano()
		dt := float64(t2 - t1) / 1000000.0
		fmt.Println(dt)
		ch2 <- 0.0
	}()
	go func() {
		for i := 0; i < 1000000; i++ {
			ch := <-ch1
			ch <- i
		}
		ch2 <- 0.0
	}()

	<-ch2
	<-ch2
}


результат 1 поток
users-iMac:channel user$ go run channel03.go 
NumCPU 4
1041.489

результат 4 потока
users-iMac:channel user$ go run channel03.go 
NumCPU 4
11170.616

Поэтому, если у вас 8 ядер и вы пишите сервер на Go, вам не стоит полностью полагаться на Go в распараллеливании программы, а может, запустить 8 однопоточных процессов, а перед ними балансировщик, который тоже можно написать на Go. У нас в продакшине был сервер, который при переходе с одно-ядерного сервера на 4х стал обрабатывать на 10% меньше запросов.

Что значат эти цифры? Перед нами стояла задача обрабатывать 3000 запросов в секунду в одном контексте (например, выдавать каждому запросу последовательно числа: 1, 2, 3, 4, 5… может, чуть сложней) и производительность 3000 запросов в секунду ограничивается в первую очередь каналами. С добавлением потоков и ядер производительность растет не так рьяно, как хотелось. 3000 запросов в секунду для Go — это некий предел на современном оборудовании.

Ночной Update: Как нельзя оптимизировать



Комменты из статьи “Go: Два года в продакшне” побудили меня написать эту статью, но комменты этой превзошли комменты первой.

Хабражитель cybergrind предложил следующую оптимизацию. Она очень понравилась уже 8 другим хабражителям. Не знаю, читали они код или может они дайверы и все делают интуитивно, но я поясню. Так статья станет более полной и информативной.
Вот код:

package main
 
import "fmt"
import "time"
import "runtime"
 
 
func main() {
 
    numcpu := runtime.NumCPU()
    fmt.Println("NumCPU", numcpu)
    //runtime.GOMAXPROCS(numcpu)
    runtime.GOMAXPROCS(1)
 
    ch3 := make(chan int)
    ch1 := make(chan int, 1000000)
    ch2 := make(chan float64)
 
 
    go func() {
 
        for i := 0; i < 1000000; i++ {
            ch1 <- i
        }
        ch3 <- 1
        ch1 <- -1
        ch2 <- 0.0
 
    }()
    go func() {
        fmt.Println("TT", <-ch3)
        total := 0.0
        for {
            t1 := time.Now().UnixNano()
            for i := 0; i < 100000; i++ {
                m := <-ch1
                if m == -1 {
                    ch2 <- total
                }
            }
            t2 := time.Now().UnixNano()
            dt := float64(t2 - t1) / 1000000.0
            total += dt
            fmt.Println(dt)
        }
    }()
 
    fmt.Println("Total:", <-ch2, <-ch2)
}


В чем суть этой оптимизации?

1. Добавлен канал ch3. Этот канал блокирует вторую гоурутину, до окончания первой гоурутины.
2. Так как вторая гоурутина не читает из канала ch1, то он блокирует первую гоурутину при заполнении. Поэтому ch1 увеличен до необходимого 1,000,000

То есть, код больше не параллелен, работает последовательно, а канал используется как массив. И конечно этот код не в состоянии использовать второе ядро. В контексте этого кода нельзя говорить о “идеальном ускорении в N раз“.

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

Update 2: Тесты на Go 1.1.2



тест номер один с буфером 1 (channel01.go)

	ch1 := make(chan chan int, 1)


1 поток
go runchannel01.go
NumCPU 4
66.0038
66.0038
67.0038
66.0038
67.0038
66.0038
65.0037
67.0038
67.0039
76.0043
Total: 0 673.0385000000001


4 потока
go run channel01.go
NumCPU 4
116.0066
186.0106
112.0064
117.0067
175.01
115.0066
114.0065
148.0084
133.0076
153.0088
Total: 0 1369.0782

Вывод: значительно лучше. Зачем ставить буфер 1 трудно представить, но возможно есть применение у такого буфера.

тест номер один с буфером 100 (channel01.go)

	ch1 := make(chan chan int, 100)


1 поток
go run channel01.go
NumCPU 4
16.0009
17.001
16.0009
16.0009
16.0009
16.0009
17.001
16.0009
17.001
16.0009
Total: 0 163.00930000000002


4 потока
go runchannel01.go
NumCPU 4
66.0038
66.0038
67.0038
66.0038
67.0038
66.0038
65.0037
67.0038
67.0039
76.0043
Total: 0 673.0385000000001

Вывод: в два раза хуже, чем версия 1.0.2

тест номер два (channel03.go)

1 поток
go run channel03.go
NumCPU 4
1568.0897


4 потока
go run channel03.go
NumCPU 4
12119.6932


Примерно так же как версия 1.0.2, но чуть лучше. 1:8 против 1:10

+36
34.6k 94
Comments 56
Top of the day