1.语言进阶
1.1 语言进阶
从并发编程的视角了解Go高性能的本质。
1.1.1 并发&并行 在单核CPU下,线程实际还是串行执行的。操作系统中有一个组件叫做任务调度器,它将CPU的时间片(window下最小约为15毫秒)分给不同的程序使用,只是由于CPU在线程间的切换非常快,人类一般是感觉不到的,所以会觉得他们是同时运行的。一句话说就是微观串行,宏观并行。
一般将这种线程轮流使用CPU的做法称为并发,concurrent,多核 cpu下,每个 核(core) 都可以调度运行线程,这时候线程可以是并行的。
这里引用Golang语言创造者的Rob Pike的一句描述:
并发(concurrent)是同一时间应对(dealing with)多件事情的能力
并行(parallel)是同一时间动手做(doing)多件事情的能力
1.1.2 线程&协程
线程
一个进程之内可以分为一到多个线程,一个线程就是一个指令流,指令流的一条条指令按照一定的顺序加载给CPU执行,比如在Java中,线程作为最小的调度单位,进程作为资源分配的最小单位。
协程
协程不是系统线程,很多时候被称为轻量级线程、微线程、纤程等,简单来说可以认为协程是线程里面不同的函数,这些函数之间可以相互快速切换,协程和用户线态线程非常接近,用户态线程之间的切换不需要陷入内核,但这不是绝对的,部分系统中的切换也是需要内核态线程的辅助的。协程是编程语言提供的特性(之间的切换方式与过程可以由程序员确定),属于用户态操作。
在Go语言中开启协程只需要在函数调用之前加上go关键字即可。比如下面这段代码,通过协程的方式,打印一段输出。
package main
import (
"fmt"
"time"
)
func main() {
HelloGoRoutine()
}
func hello(i int) {
println("hello goroutine:" + fmt.Sprint(i))
}
func HelloGoRoutine() {
for i := 0; i < 5; i++ {
//开启协程
go func(j int) {
hello(j)
}(i)
}
time.Sleep(time.Second)
}
Go的CSP并发模型
与其他编程语言不同,Go语言除了支持传统语言的 多线程共享内存并发模型之外,还有自己特有的 **CSP(communicating sequential processes)**并发模型,一般情况下,这也是Go语言推荐使用的。
与传统的 多线程通过内存来通信 相比,CSP讲究的是 以通信的方式来共享内存。
普通的线程并发模型,他们的线程通信一般是通过共享内存的方式来进行的,非常典型的方式就是,在访问共享数据(数组、Map等)的时候是通过锁来访问,因此很多时候会衍生出一种叫做 线程安全的数据结构的东西。
而Go的CSP并发模型则是通过goroutine和channel来实现的。
Channel
channel类似与一个队列,满足先进先出的规则,严格保证收发数据的顺序,每一个通道只能通 过固定类型的数据如果通道进行大型结构体、字符串的传输,可以将对应的指针传进去,尽量的节省空间。
在Go中,可以通过make函数创建通道。下面通过一段简单的生产-消费模式的代码示例,熟悉channel的基本使用。
package main
func main() {
CalSquare()
}
func CalSquare() {
//无缓冲channel
src := make(chan int)
//有缓冲channel
dest := make(chan int, 3)
//协程A生成0-9数字
go func() {
defer close(src)
for i := 0; i < 10; i++ {
src <- i
}
}()
//协程B计算输入数字的平方
go func() {
defer close(dest)
for i := range src {
dest <- i * i
}
}()
//主线程打印输出最终结果
for i := range dest {
//TODO
println(i)
}
}
并发安全Lock—传统并发模式
考虑下面这个场景:
package main
import (
"sync"
"time"
)
var (
x int64
lock sync.Mutex
)
func main() {
Add()
}
// 加锁的自增方法
func addWithLock() {
for i := 0; i < 2000; i++ {
lock.Lock()
x += 1
lock.Unlock()
}
}
// 不加锁的自增方法
func addWithoutLock() {
for i := 0; i < 2000; i++ {
x += 1
}
}
func Add() {
x = 0
for i := 0; i < 5; i++ {
go addWithoutLock()
}
time.Sleep(time.Second)
println("没加锁:", x)
x = 0
for i := 0; i < 5; i++ {
go addWithLock()
}
time.Sleep(time.Second)
println("加锁的:", x)
}
这里导入了一个名为sync的库。其中的Mutex类型直接支持互斥锁关系,它的Lock方法能够获取锁和释放锁Unlock。每个goroutine试图访问x变量时,他都会调用mutex的Lock方法来获取一个互斥锁。如果其他goroutine已经获得这个锁的情况下,该操作将会被阻塞,直到其他goroutine调用了Unlock方法释放锁变回可用状态。
在Lock和Unlock直接的代码段中的内容goroutine可以随便读取或修改,这个这个代码叫做临界区(注意前面提到的两种并发模式的示意图)。锁的持有者在其他goroutine获取该锁之前需要调用Unlock,任务执行结束之后释放锁是必要的,无论哪一条路径通过函数都需要释放,即使是在出现异常的情况下。
在上面的代码中,由于变量x的自增函数中只有短短的一行,没有分支调用,在代码最后调用Unlock就显得更加的直截了当。但在复杂的临界区应用中,尤其是必须要尽早处理错误并返回的情况下,就很难靠认为的去判断对Lock和Unlock的调用是在所有的路径中都是严格配对的了。所以面对这种情况,Go也是有自己独有的应对方法,使用go中的defer,可以使用defer来调用Unlock,临界区会隐式的延伸到函数作用域的最后,这样我们就无需每次都要记得使用Unlock去释放锁,Go会帮我们完成这件事。
上面的程序中,同样都是启用5个协程并发对x自增2000次,一个使用了lock一个没有,在并发情况下,没有加锁的操作可能会引起数据的错误,比如未加锁情况下最终的是8337,而加锁的情况下确实正确结果10000,可见加锁在一定程度上可以防止数据错误,保证了原子性。