前言:

写出一个高性能的程序,肯定要关注程序的并行特性,那么运行并发,我们关注什么性能指标。比如表象上我们关注 并发的上限,创建并发数据结构的最小开销,切换时间开销。如果在C里面,我们往往用多线程实现一个高并发的服务程序,我们会关注他的多线程创建,以及线程间上下文切换、或者多线程切换背后陷入的系统调用的销毁。那么当前golang能做到更好的并发吗,对比c提升了多少,以及做到更高效率的背后真相是什么?本文一一用案板的事实分析出来。

1.1 每秒创建的上限

比如现关心固定时间内可以创建的并发条目是多少,以大数定律,创建较多的数量,总的创建时间/线程数即是每个线程的时间。

g++ pthread_create.c -std=c++11 -lpthread -o3 -o test

time计时用了14.60s. 每个线程数创建时间为29us。另外知道多线程是共享内存地址空间,每个线程的栈大小2M,可以更改默认值。所以系统所能同时拥有的线程数量是受内存限制的。比如说写一个一直创建线程的测试程序,直到内存不足pthred_create返回失败。往往机器能同时创建的数量几万个。

相对于看下golang的gorouine创建开销。写个空循环的goroutine。并且创建多个,对比下内存增长。相关代码在此:https://github.com/lumanyu/go-concurrency/blob/master/memconsumed.go。每个gorontine的平均内存开销为7B。所以从内存来说,一台主流的64G内存的服务器可以同时拥有千万个goroutine。

1.2 上下文切换的上限

每个线程是cpu调度的基本单位,所以线程拥有独立的PC计数器、寄存器、栈地址。线程间的切换指的是保存和恢复这些PC、SP等。这里面线程又分为系统线程和用户线程。这里对比的是系统线程。用户线程由于把内核线程切换的逻辑在应用层实现,所以又更快的效率,但是在系统看来还是单个线程,所以只要有一个线程陷入阻塞,整片线程都阻塞住。另外用户态线程拿到的时间是系统分给一个线程的时间,所以多个用户态线程或者一个用户态线程他们所拥有的cpu运行时间是相同的。

我们用perf 的bench工具测量线程间的切换性能,原理是通过pipe在两个线程收发数据,测试所能完成一次pipe的时间,为表示公平,测试命令用taskset设置在cpu第0核上。

taskset -c 0 perf bench sched pipe -T

看起来似乎还行。切换上下文用的时间就是3.63/2=1.8us每次。但是想想比如在一台线上qps并发度为1w的机器。如果有进行1w次线程切换。那么1s的时间内总共需要总切换的时间=18ms。没有对比就没有伤害。来看下goroutine的切换时间。代码在https://github.com/lumanyu/go-concurrency/blob/master/contextswitch_test.go。

go test -bench=. -cpu=1 contextswitch_test.go的结果如下。goroutine的171ns比1.8us有10倍提升。

1.3 高效机制背后的秘密

  • goroutine既不是os线程。也不是应用层线程。协程是一种非抢占式简单并发的goroutine(函数,闭包或者方法。)不能被中断。取而代之的是,协程有多个point,允许暂停或者重新进入。
  • golang的M:N调度器。当M>N时候,golang会处理分布在可用线程上的goroutine,当被阻塞时,其他的goroutine可用运行。
  • golang的轻:一个新创建的goroutine赋予了几千字节,不运行,go会自动缩减内存。每个函数调用相当于3个cpu指令。如果goroutine是线程,资源消耗会更小。
  • goroutine的fork-join模型。在程序的任一节点,可用让父子可以同时运行,并在未来的某一节点合并在一起。如果没有合并,子分支甚至可能没有机会运行,因为此时主分支已经退出。

1.4 业务上的上限(更快响应,最优上限)

又回到gorountine。我们来研究下goroutine的调度机制。做个代码测试https://github.com/lumanyu/go-concurrency/blob/master/preemptive.go

这个代码测试了两个goroutine的表现。在preemptive函数片段中,只要一个goroutine阻塞于sleep函数,那么另外一个goroutine会接过cpu的使用权。看到的打印是这两个函数交互打印。另外在notpreemptive函数片段,因为没有sleep,没有channel阻塞,也没有sync.mutex等同步原语阻塞。一个goroutine会运行到结素,才会交回cpu控制权。

golang的调度理论,只在阻塞的时候抢占

更多的goroutine意味着可能带来的资源竞争,以及临界区保护的重试开销。

go并发和线程池处理连接的区别

线程池:系统每次连接不用创建那么多的线程,用线程池做到连接复用。线程的上下文切换必须保存寄存器值(cpu寄存器,程序寄存器),IDT表查找(内核线程,系统调用),内存映射关系(这是什么?)

池子:创建固定数量实例,用于建设创建销毁开销。不确定的操作使用确定的数量的池子。

1.5 内存回收

goroutine虽然廉价且易于创建。但是goroutine毕竟也是消耗资源,而且goroutine不会被运行GC回收。良好的父子goroutine需要以一个“done”的channel告诉子goroutine停止工作。

另外go 1.8开始已经实现了低延时的GC。暂停实时性的服务来GC是不可接受的,go语言已经把这个GC暂停缩短在10~100us之间。

2.1 争锁的次数越少越好(减少争用,怎么减少,有模型吗)

在传统并发编程领域,争锁更倾向于保护关键的临界区,而且要及时释放锁,而不是贪婪的一直占用锁。另外在读写场合,多个读端 使用读写锁

2.2 有没有数据同步,怎么做数据同步

golang的CSP(Communication Sequential Processes),强调通过通信来共享内存,而不是c语言的通过共享内存来通信。(传统比如mutex,现在用channel)。当然为了兼容,go也提供sync包的mutex来实现,让程序员有了更多选择。channel或者channel+select就是golang实践CSP的产物。那么如果选择sync包mutex vs CSP怎么作出选择。使用哪一种的决策树。如果是对性能要求很高的临界区或者试图保护结构的某个内部状态比较倾向于传统的mutex。如果是转让数据的所有权或者试图协调多个逻辑片段则用CSP。

有个小细节同一地址空间怎么来保证变量的共享,因为主的可能退出,所以需要某种机制。那如果就访问到那些不存在的地址空间怎么办,golang会把内存移到堆(是吗,其实程序跑起来 地址是没变的)。

2.3 死锁的依据和检测

coffman的死锁具备理论: 进程独占资源并排斥其他进程获取,等待其他资源,资源拥有的进程才有资格释放。循环等待。避免办法:就是让任一个 条件不成立

golang有个检测死锁的进程,只要是locked的进程指的是goroutine阻塞于channel或者mutex

goroutine的数目all - idle - locked > 0,那么机器还可以分配新的的gorountine,如果这个树等于0那么就是死锁了。

什么是活锁?

golang还提供了-race参数检测代码中可能存在的竞争