tight loop

什么是“tight loop”?
在汇编语言中,一个包含少量指令并迭代多次的循环。
这种大量使用I / O或处理资源的循环,无法与运行在操作系统中的其他程序充分共享。
可能是



for (unsigned int i = 0; i < 0xffffffff; ++ i) {}
这个短语通常用于指定一个迭代次数很多的循环,这会对程序的性能产生严重影响。可以使用大量的CPU周期。



游戏中一个循环可能需要处理屏幕上的每个像素,或者应用程序,其中一个循环正在处理巨大数据点阵列中的条目。

(Go版本1.12、1.13结果类似,Go1.14正常)



package main



import (
“fmt”
“runtime”
“sync”
“time”
)



func main() {
wg := sync.WaitGroup{}
wg.Add(1000000) // 1)使用这行大约 TotalAlloc = 511 MiB 花费3s
for i := 0; i < 1000000; i++ {
//wg.Add(1) // 2)使用这行大约 TotalAlloc = 74 MiB 花费0.5s
go func() {
time.Sleep(time.Duration(1) * time.Millisecond)
wg.Done()
}()
}
wg.Wait()
mem := runtime.MemStats{}
runtime.ReadMemStats(&mem)
curMem := mem.TotalAlloc / 1024 / 1024
fmt.Printf(“\tTotalAlloc = %v MiB \n”, curMem)
}
解释:



很多时候查问题就像是做一道智力题,表象是这样,然而实际原因和表象却不相干。



表象是waitgroup问题,然而从waitgroup原理和并发冲突去理解的都是歧途,一开始我也是这么想的.



其实是go1.14之前的tight loop不能被gc中断导致的问题.



大概生成上千次个协程时, 这时候内存到了gc阈值, sleep执行时会分配timer, 这时知道要gc了,告诉所有协程和P, 现在要gc了, 你们要停止.加了wg.Add(1)的那个, 正好Add方法有点复杂, 调用前有抢占点检查, 所以主协程可以被gc中断,gc得以执行完,而sleep完后的协程可以被复用.



而先Add(1000000)的那个, 主协程的for循环中没有检查自身被中断的地方. 虽然主协程也能在分配g时得知要gc了, 但是创建一个协程是由g0和systemstack执行, 这里面不能发起gc, 就返回继续执行. 所以主流程协程一直在运行.



调度timer的线程需要P才能恢复sleep的协程, 所有的P都处于gc暂停态, 使得sleep的协程也不能恢复并运行完,也就不能被复用. 主流程就一直分配新的协程, 之前那些sleep的协程不能被复用, 内存和CPU都增加很多.



减少分配
确保你的 APIs 不会给调用方增加垃圾。



考虑这两个 Read 方法



func (r *Reader) Read() ([]byte, error)
func (r *Reader) Read(buf []byte) (int, error)
第一个 Read 方法不带参数,并将一些数据作为[]byte返回。 第二个采用[]byte缓冲区并返回读取的字节数。



第一个 Read 方法总是会分配一个缓冲区,这会给 GC 带来压力。 第二个填充传入的缓冲区。



strings vs []bytes
Go 语言中 string 是不可改变的,而 []byte 是可变的。



大多数程序喜欢使用 string,而大多数 IO 操作更喜欢使用 []byte。



尽可能避免 []byte 到 string 的转换,对于一个值来说,最好选定一种表示方式,要么是[]byte,要么是string。 通常情况下,如果你从网络或磁盘读取数据,将使用[]byte 表示。



bytes 包也有一些和 strings 包相同的操作函数—— Split, Compare, HasPrefix, Trim等。



实际上, strings 使用和 bytes 包相同的汇编原语。



使用 []byte 当做 map 的 key
使用 string 作为 map 的 key 是很常见的,但有时你拿到的是一个 []byte。



编译器为这种情况实现特定的优化:



var m map[string]string
v, ok := m[string(bytes)]
如上面这样写,编译器会避免将字节切片转换为字符串到 map 中查找,这是非常特定的细节,如果你像下面这样写,这个优化就会失效:



key := string(bytes)
val, ok := m[key]
优化字符串连接操作
Go 的字符串是不可变的。连接两个字符串就会生成第三个字符串。下面哪种写法是最快的呢?



s := request.ID
s += “ “ + client.Addr().String()
s += “ “ + time.Now().String()
r = s
var b bytes.Buffer
fmt.Fprintf(&b, “%s %v %v”, request.ID, client.Addr(), time.Now())
r = b.String()
r = fmt.Sprintf(“%s %v %v”, request.ID, client.Addr(), time.Now())
b := make([]byte, 0, 40)
b = append(b, request.ID…)
b = append(b, ‘ ‘)
b = append(b, client.Addr().String()…)
b = append(b, ‘ ‘)
b = time.Now().AppendFormat(b, “2006-01-02 15:04:05.999999999 -0700 MST”)
r = string(b)
% go test -bench=. ./examples/concat/
我的测试结果:



go 1.10.3
goos: darwin
goarch: amd64
pkg: test/benchmark
BenchmarkConcatenate-8 2000000 873 ns/op 272 B/op 10 allocs/op
BenchmarkFprintf-8 1000000 1509 ns/op 496 B/op 13 allocs/op
BenchmarkSprintf-8 1000000 1316 ns/op 304 B/op 11 allocs/op
BenchmarkStrconv-8 2000000 620 ns/op 165 B/op 5 allocs/op
PASS
go 1.11
goos: darwin
goarch: amd64
pkg: test/benchmark
BenchmarkConcatenate-8 1000000 1027 ns/op 271 B/op 10 allocs/op
BenchmarkFprintf-8 1000000 1707 ns/op 496 B/op 12 allocs/op
BenchmarkSprintf-8 1000000 1412 ns/op 304 B/op 11 allocs/op
BenchmarkStrconv-8 2000000 707 ns/op 165 B/op 5 allocs/op
PASS
所有的基准测试在1.11版本下都变慢了?



已知长度时,切片一次分配好
Append 操作虽然方便,但是有代价。



切片的增长在元素到达 1024 个之前一直是两倍左右地变化,在到达 1024 个之后之后大约是 25% 地增长。在我们 append 之后的容量是多少呢?



func main() {
b := make([]int, 1024)
fmt.Println(“len:”, len(b), “cap:”, cap(b))
b = append(b, 99)
fmt.Println(“len:”, len(b), “cap:”, cap(b))
}
output:
len: 1024 cap: 1024
len: 1025 cap: 1280
如果你使用 append,你可能会复制大量数据并产生大量垃圾。



如果事先知道片的长度,最好预先分配大小以避免复制,并确保目标的大小完全正确。



Before:



var s []string
for _, v := range fn() {
s = append(s, v)
}
return s
After:



vals := fn()
s := make([]string, len(vals))
for i, v := range vals {
s[i] = v
}
return s
Goroutines
使 Go 非常适合现代硬件的关键特性是 goroutines。goroutine 很容易使用,成本也很低,你可以认为它们几乎是没有成本的。



Go 运行时是为运行数以万计的 goroutines 所设计的,即使有上十万也在意料之中。



但是,每个 goroutine 确实消耗了 goroutine 栈的最小内存量,目前至少为 2k。



2048 * 1,000,000 goroutines == 2GB 内存,什么都不干的情况下。



这也许算多,也许不算多,同时取决于机器上其他耗费内存的应用。



要了解 goroutine 什么时候退出
虽然 goroutine 的启动和运行成本都很低,但它们的内存占用是有限的;你不可能创建无限数量的 goroutine。



每次在程序中使用go关键字启动 goroutine 时,你都必须知道这个 goroutine 将如何退出,以及何时退出。



如果你不知道,那这就是潜在的内存泄漏。



在你的设计中,一些 goroutine 可能会一直运行到程序退出。这样的 goroutine 不应该太多



永远不要在不知道该什么时候停止它的情况下启动一个 goroutine



实现此目的的一个好方法是利用如 run.Group, workgroup.Group 这类的东西。



Peter Bourgon has a great presentation on the design behing run.Group from GopherCon EU



进一步阅读
Concurrency Made Easy (视频)
Concurrency Made Easy (幻灯片)
Go 对一些请求使用高效的网络轮询
Go 运行时使用高效的操作系统轮询机制(kqueue,epoll,windows IOCP等)处理网络IO。 许多等待的 goroutine 将由一个操作系统线程提供服务。



但是,对于本地文件IO(channel 除外),Go 不实现任何 IO 轮询。每一个*os.File在运行时都消耗一个操作系统线程。



大量使用本地文件IO会导致程序产生数百或数千个线程;这可能会超过操作系统的最大值限制。



您的磁盘子系统可能处理不数百或数千个并发IO请求。



注意程序中的 IO 复杂度
如果你写的是服务端程序,那么其主要工作是复用网络连接客户端和存储在应用程序中的数据。



大多数服务端程序都是接受请求,进行一些处理,然后返回结果。这听起来很简单,但有的时候,这样做会让客户端在服务器上消耗大量(可能无限制)的资源。下面有一些注意事项:



每个请求的IO操作数量;单个客户端请求生成多少个IO事件? 如果使用缓存,则它可能平均为1,或者可能小于1。
服务查询所需的读取量;它是固定的?N + 1的?还是线性的(读取整个表格以生成结果的最后一页)?
如果内存都不算快,那么相对来说,IO操作就太慢了,你应该不惜一切代价避免这样做。 最重要的是避免在请求的上下文中执行IO——不要让用户等待磁盘子系统写入磁盘,甚至连读取都不要做。



使用流式 IO 接口
尽可能避免将数据读入[]byte 并传递使用它。



根据请求的不同,你最终可能会将兆字节(或更多)的数据读入内存。这会给GC带来巨大的压力,并且会增加应用程序的平均延迟。



作为替代,最好使用io.Reader和io.Writer构建数据处理流,以限制每个请求使用的内存量。



如果你使用了大量的io.Copy,那么为了提高效率,请考虑实现io.ReaderFrom / io.WriterTo。 这些接口效率更高,并避免将内存复制到临时缓冲区。



超时,超时,还是超时
永远不要在不知道需要多长时间才能完成的情况下执行 IO 操作。



你要在使用SetDeadline,SetReadDeadline,SetWriteDeadline进行的每个网络请求上设置超时。



您要限制所使用的阻塞IO的数量。 使用 goroutine 池或带缓冲的 channel 作为信号量。



var semaphore = make(chan struct{}, 10)



func processRequest(work *Work) {
semaphore <- struct{}{} // 持有信号量
// 执行请求
<-semaphore // 释放信号量
}
Defer 操作成本如何?
defer 是有成本的,因为它必须为其执行参数构造一个闭包去执行。



defer mu.Unlock()
相当于



defer func() {
mu.Unlock()
}()
如果你用它干的事情很少,defer 的成本就会显得比较高。一个经典的例子是使用defer对 struct 或 map 进行mutex unlock 操作。 你可以在这些情况下避免使用defer



当然,这是为了提高性能而牺牲可读性和维护性的情况。



总是重新考虑这些决定。
避免使用 Finalizers
终结器是一种将行为附加到即将被垃圾收集的对象的技术。



因此,终结器是非确定性的。



要运行 Finalizers,要保证任何东西都不会访问该对象。 如果你不小心在 map 中保留了对象的引用,则 Finalizers 无法执行。



Finalizers 作为 gc 的一部分运行,这意味着它们在运行时是不可预测的,并且它会与 减少 gc 时间 的目标相悖。



当你有一个非常大的堆块,并且已经优化过你的程序使之减少生成垃圾,Finalizers 可能才会很快结束。



提示 :参考 SetFinalizer



最小化 cgo
cgo 允许 Go 程序调用 C 语言库。



C 代码和 Go 代码存在于两个不同的世界中,cgo 用来转换它们。



这种转换不是没有代价的,主要取决于它在代码中的位置,有时成本可能很高。



cgo 调用类似于阻塞IO,它们在操作期间消耗一个系统线程。



不要在一个 tight loop 中调用 C 代码。



实际上,避免使用 cgo
cgo 的开销很高。



为了获得最佳性能,我建议你在应用中避免使用cgo。



如果C代码需要很长时间,那么 cgo 本身的开销就不那么重要了。
如果你使用 cgo 来调用非常短的C函数,那么cgo本身的开销就会显得非常突出,那么最好的办法是在 Go 中重写该代码。(因为很短,重写也没什么成本。
如果你就是要使用大量高开销成本的C代码在 tight loop 中调用,为什么使用 Go?(直接用 C 写就好了被。
始终使用最新版发布的 Go 版本
Go 的旧版本永远不会变得更好。他们永远不会得到错误修复或优化。



Go 1.4 不应该再使用。
Go 1.5 和 1.6 编译器的速度更慢,但它产生更快的代码,并具有更快的 GC。
Go 1.7 的编译速度比 1.6 提高了大约 30%,链接速度提高了2倍(优于之前的Go版本)。
Go 1.8 在编译速度方面带来较小的改进,且在非Intel体系结构的代码质量方面有显著的改进。
Go 1.9,1.10,1.11 继续降低 GC 暂停时间并提高生成代码的质量。
Go 的旧版本不会有任何更新。 不要使用它们。 使用最新版本,你将获得最佳性能。



https://github.com/sxs2473/go-performane-tuning


Category golang