一 、什么是goroutine
1.1 3种不同的线程
用户级线程:
用户级线程是通过运行在用户态的运行时库来管理的,其优点是,线程的一切(包括调度、创建)都可以完全由用户自己决定,所以具有较高的灵活性。而且由于是在用户态上进行管理,所以就省去了内核管理的开销,所以具有高效率。 但是用户级线程有一个致命的缺点:一个进程内的某一个线程阻塞将导致整个进程内的所有线程全部阻塞。而且由于用户级线程没有时间片概念,所以每个线程必须运行一段时间后将CPU让个其他的线程使用,否则,该线程将独占CPU。
- 用户级线程的优点: 有较高的灵活性和高效率
- 用户级线程的缺点: 较差并发能力
内核级线程:
内核级线程是操作系统内核实现、管理和调度的一种线程。由于有操作系统管理,所以操作系统是知道线程的存在,并为其安排时间片,管理与其有关的内核对象。因为内核级线程是由内核来管理,所以每次线程创建、切换都要执行一个模式切换例程,所以内核级线程效率比较低,而且内核级线程的调度是由操作系统的设计者来决定的,所以缺乏灵活性。但是内核级线程有一个有点就是当一个进程的某个线程因为一个系统调用或者缺页中断而阻塞时,不会导致该进程的所有线程阻塞。
- 内核级线程的优点: 较好的并行能力,一个进程内的线程阻塞不会影响该进程内的其他线程
- 内核级线程的缺点: 线程管理的开销过大,缺乏灵活性。
两级线程:
在一些系统中,使用组合方式的多线程实现, 线程创建完全在用户空间中完成,线程的调度和同步也在应用程序中进行。一个应用程序中的多个用户级线程被映射到一些(小于或等于用户级线程的数目)内核级线程上。
总结:
1.2 goroutine的特性
很多人认为goroutine比线程运行得更快,这是一个误解。Goroutine并不会更快,它只是增加了更多的并发性。当一个goroutine被阻塞(比如等待IO),golang的scheduler会调度其它可以执行的goroutine运行。与线程相比,它有以下几个优点:
开销小:
goroutine所需要的内存通常只有2kbg,而线程则需要1Mb(500倍)。
调度快:
由于线程创建时需要向操作系统申请资源,并且在销毁时将资源归还,因此它的创建和销毁的开销比较大。相比之下,goroutine的创建和销毁是由go语言在运行时自己管理的,因此开销更低。goroutine的调度是协同式的,它不会直接地与操作系统内核打交道。当goroutine进行切换的时候,之后很少量的寄存器需要保存和恢复(PC和SP)。因此gouroutine的切换效率更高。
GC回收:
GC会周期性地将不再使用的内存回收,收缩栈空间。
三色标记法
- 灰色:对象已被标记,但这个对象包含的子对象未标记
- 黑色:对象已被标记,且这个对象包含的子对象也已标记,gcmarkBits对应的位为1(该对象不会在本次GC中被清理)
- 白色:对象未被标记,gcmarkBits对应的位为0(该对象将会在本次GC中被清理)
例如,当前内存中有A~F一共6个对象,根对象a,b本身为栈上分配的局部变量,根对象a、b分别引用了对象A、B, 而B对象又引用了对象D,则GC开始前各对象的状态如下图所示:
- 初始状态下所有对象都是白色的。
- 接着开始扫描根对象a、b; 由于根对象引用了对象A、B,那么A、B变为灰色对象,接下来就开始分析灰色对象,分析A时,A没有引用其他对象很快就转入黑色,B引用了D,则B转入黑色的同时还需要将D转为灰色,进行接下来的分析。
- 灰色对象只有D,由于D没有引用其他对象,所以D转入黑色。标记过程结束
- 最终,黑色的对象会被保留下来,白色对象会被回收掉。
1.3 G-P-M模型
- M:代表内核级线程,一个M就是一个线程,goroutine就是跑在M之上的;M是一个很大的结构,里面维护小对象内存cache(mcache)、当前执行的goroutine、随机数发生器等等非常多的信息。
- P:P全称是Processor,处理器,它的主要用途就是用来执行goroutine的,所以它也维护了一个goroutine队列,里面存储了所有需要它来执行的goroutine。
- G:代表一个goroutine,它有自己的栈,instruction pointer和其他信息(正在等待的channel等等),用于调度。
Go调度器工作时会维护两种用来保存G的任务队列:一种是一个Global任务队列,一种是每个P维护的Local任务队列。当通过go关键字创建一个新的goroutine的时候,它会优先被放入P的本地队列。为了运行goroutine,M需要持有(绑定)一个P,接着M会启动一个OS线程,循环从P的本地队列里取出一个goroutine并执行。当M执行完了当前PLocal队列里的所有G后,它会先尝试从Global队列寻找G来执行,如果Global队列为空,它会随机挑选另外一个P,从它的队列里中拿走一半的G到自己的队列中执行。
二、goroutine高性能编程
2.1 连接池优化
既然Go调度器这么优秀,我们为什么还要自己去实现一个golang的 Goroutine Pool 呢?有基于G-P-M的Go调度器,go程序的并发编程中,可以任性地起大规模的goroutine来执行任务,官方也宣称用golang写并发程序的时候随便起个成千上万的goroutine毫无压力。然而,起1000个goroutine没有问题,10000也没有问题,10w个可能也没问题;那,100w个呢?1000w个呢?
- 首先,即便每个goroutine只分配2KB的内存,但如果是恐怖如斯的数量,聚少成多,内存暴涨,就会对GC造成极大的负担,如果太过于频繁地进行GC,依然会有性能瓶颈。
- 其次,runtime和GC也都是goroutine!如果goroutine规模太大,内存吃紧,runtime调度和垃圾回收同样会出问题,虽然G-P-M模型足够优秀,但没有内存,Go调度器就会阻塞goroutine,结果就是PLocal队列积压,又导致内存溢出,这就是个死循环...,甚至极有可能程序直接Crash掉。
2.2 内存优化
对象优化:
- 对象拷贝考虑使用指针引用,减少内存分配。
- 创建频繁的对象,考虑使用sync.Pool对象池,进行对象复用
- 优化对象结构,精简属性字段
- 小对象合并成结构体一次分配,减少内存分配次数
变量优化:
- 尽量使用局部变量。
- 多个局部变量合并一个大的结构体或数组,减少扫描对象的次数,一次回尽可能多的内存。
- 变量拷贝考虑使用指针引用,减少内存分配。
数据压缩:
- 对大数据传输使用压缩算法进行压缩,大小缩减到原数据大小的1/5以内。
- 优化数据结构,减少嵌套层级,精简字段大小,合理使用字段类型。
- 协议封装复用内存资源和对象资源,根据流量预估,给出最优复用方案。
关于字符串的使用建议:
- 字符串拼接优先考虑bytes.Buffer。
- 减少[]byte和string的转换,统一使用[]byte处理。
- bytes.Buffer等通过预先分配足够大的内存,避免当Grow时动态申请内存,这样可以减少内存分配次数。
- 频繁操作大字符串,考虑用sync.Pool对象池,进行内存复用,合理设置内存池新对象的内存空间。
字符串拼接的三种方式:
1.fmt.Sprintf :会内部使用 []byte实现,不像直接运算符这种会产生很多临时的字符串,但是内部的逻辑比较复杂,有很多额外的判断,还用到了 interface,所以性能也不是很好。
func Sprintf(b *testing.B) {
hello := "hello"
world := "world"
for i := 0; i < b.N; i++ {
_ = fmt.Sprintf("%s,%s", hello, world)
}
}
2.strings.Join :内部是[]byte的append。join会先根据字符串数组的内容,计算出一个拼接之后的长度,然后申请对应大小的内存,一个一个字符串填入,在已有一个数组的情况下,这种效率会很高,但是本来没有,去构造这个数据的代价也不小。
func Join(b *testing.B) {
hello := "hello"
world := "world"
for i := 0; i < b.N; i++ {
_ = strings.Join([]string{hello, world}, ",")
}
}
3.bytes.Buffer :可以预先分配大小,减少对象分配与拷贝。
func Buffer(b *testing.B) {
hello := "hello"
world := "world"
for i := 0; i < b.N; i++ {
var buffer bytes.Buffer
buffer.WriteString(hello)
buffer.WriteString(",")
buffer.WriteString(world)
_ = buffer.String()
}
}
三、goroutine管理
- Goroutine尽量独立,无冲突地执行;若goroutine间存在冲突,则可以采分区来控制goroutine的并发个数,减少同一互斥对象冲突并发数。
- Go中的推荐是不要通过共享内存来通讯,Go创建goroutine非常容易,当大量goroutine共享同一互斥对象时,也会在某一数量的goroutine出在拐点。
- goroutine虽轻量,但对于高并发的轻量任务处理,频繁来创建goroutine来执行,执行效率并不会太高效,考虑使用goroutine池。
- 把涉及到同步调用的goroutine,隔离到可控的goroutine中,而不是直接高并的goroutine调用。
- goroutine的实现,是通过同步来模拟异步操作。在如下操作操作不会阻塞go runtime的线程调度(网络IO、锁、channel、time.sleep)。下面阻塞会创建新的调度线程(本地IO复用、基于底层系统同步调用的Syscall、CGO的方式调用C语言动态库中的调用IO或其他阻塞)。
- 网络IO可以基于epoll的异步机制(或kqueue等异步机制),但对于一些系统函数并没有提供异步机制。例如常见的posix api中,对文件的操作就是同步操作。
- 虽有开源的fileepoll来模拟异步文件操作。但 Go的Syscall还是依赖底层的操作系统的API。系统API没有异步,Go也做不了异步化处理。