一.进程 线程 协程
1.1进程 线程 协程分别是什么
进程是具有一定独立功能的程序关于某个数据集合上的一次运行活动,进程是系统进行资源分配和调度的一个独立单位。每个进程都有自己的独立内存空间,拥有自己独立的堆和栈,既不共享堆,亦不共享栈,进程由操作系统调度。不同进程通过进程间通信来通信。由于进程比较重量,占据独立的内存,所以上下文进程间的切换开销(栈、寄存器、虚拟内存、文件句柄等)比较大,但相对比较稳定安全。
线程是进程的一个实体,是CPU调度和分派的基本单位,它是比进程更小的能独立运行的基本单位.线程自己基本上不拥有系统资源,而拥有自己独立的栈和共享的堆,共享堆,不共享栈,线程也由操作系统调度(标准线程是这样的)。只拥有一点在运行中必不可少的资源(如程序计数器,一组寄存器和栈),但是它可与同属一个进程的其他的线程共享进程所拥有的全部资源。线程间通信主要通过共享内存,上下文切换很快,资源开销较少,但相比进程不够稳定容易丢失数据。
协程(coroutine)是一种用户态的轻量级线程,协程的调度完全由用户控制。协程和线程一样共享堆,不共享栈,协程由程序员在协程的代码里显示调度。协程拥有自己的寄存器上下文和栈。协程调度切换时,将寄存器上下文和栈保存到其他地方,在切回来的时候,恢复先前保存的寄存器上下文和栈,直接操作栈则基本没有内核切换的开销,可以不加锁的访问全局变量,所以上下文的切换非常快。
协程是对内核透明的,也就是系统并不知道有协程的存在,是完全由用户自己的程序进行调度的,因为是由用户程序自己控制,那么就很难像抢占式调度那样做到强制的 CPU 控制权切换到其他进程/线程,通常只能进行 协作式调度,需要协程自己主动把控制权转让出去之后,其他协程才能被执行到。
1.2 goroutine和协程、线程区别
1. 内存消耗方面
每个 goroutine (协程) 默认占用内存远比 Java 、C 的线程少。
goroutine:2KB
线程:8MB
2. 线程和 goroutine 切换调度开销方面
goroutine 远比线程小
线程:涉及模式切换(从用户态切换到内核态)、16个寄存器、PC、SP...等寄存器的刷新等。
goroutine:只有三个寄存器的值修改 - PC / SP / DX.
Goroutine本质是一种协程,不同的是,Golang 在 runtime、系统调用等多方面对 goroutine 调度进行了封装和处理,当遇到长时间执行或者进行系统调用时,会主动把当前 goroutine 的CPU 转让出去,让其他 goroutine 能被调度并执行,也就是 Golang 从语言层面支持了协程。Golang 的一大特色就是从语言层面原生支持协程,在函数或者方法前面加 go关键字就可创建一个协程。
“Goroutine是一个与其他goroutines 并发运行在同一地址空间的Go函数或方法。一个运行的程序由一个或更多个goroutine组成。它与线程、协程、进程等不同。它是一个goroutine”。 Go 协程意味着并行,协程一般来说不是这样的;Go 协程通过通道来通信而协程通过让出和恢复操作来通信;而且Go 协程比协程更强大。因为Golang 在 runtime、系统调用等多方面对 coroutine进行了封装和处理,也就是Golang 有自己的调度器,工作方式基本上是协作式,而不是抢占式,但也不是完全的协作式调度,例如在系统调用的函数入口处会有抢占。
1.3 go协程调度
三角形M代表内核线程,正方形P代表上下文,圆形G代表协程:
下面图我们看到他们之间的对应规则:一个M对应一个P,一个P下面挂多个G,但一个时候只有一个G在跑,其余都是放入等待队列,等待下一次切换时使用。
那么假如一个运行的协程G调用syscall进入阻塞怎么办?如下图左边,G0进入阻塞,那么P会转移到另外一个内核线程M1(此时还是1对1)。当syscall返回后,需要抢占一个P继续执行,如果抢占不到,G0挂入全局就绪队列runqueue,等待下次调度,理论上会被挂入到一个具体P下面的就绪队列runqueu(区别于全局runqueue)。
假如一个P0下面的所有G都跑完了,这时候会从别的P1下面就绪队列抢占G进行运行,个数为P1就绪队列的一半。
二.锁 原子操作
2.1互斥锁 读写锁
互斥锁
互斥锁是传统的并发程序对共享资源进行访问控制的主要手段。它由标准库代码包sync中的Mutex结构体类型代表。sync.Mutex类型(确切地说,是*sync.Mutex类型)只有两个公共方法——Lock和Unlock。顾名思义,前者被用于锁定当前的互斥量,而后者则被用来对当前的互斥量进行解锁。
var count int = 0
func add(a int, b int, lock *sync.Mutex) {
c := a + b
lock.Lock()
count++
fmt.Printf("%d+%d=%d 第%d次执行 \n", a, b, c, count)
lock.Unlock()
}
func main() {
lock := &sync.Mutex{}
a := 1
for i := 0; i < 5; i++ {
b := i
go add(a, b, lock)
}
time.Sleep(1e9)
} |
- 不能重复锁定互斥锁;
- 必须解锁互斥锁,必要时使用 defer 语句;
- 不能对尚未锁定或者已解锁的互斥锁解锁;
- 不能在多个函数之间直接传递互斥锁。
读写锁
Mutex 是最简单的一种锁类型,同时也比较暴力,当一个 goroutine 获得了 Mutex 后,其他 goroutine 就只能等到这个 goroutine 释放该 Mutex,不管是读操作还是写操作都会阻塞,为了提升性能,读操作往往是不需要阻塞的,因此 sync 包提供了 RWMutex 类型,即读/写互斥锁,简称读写锁,这是一个是单写多读模型。
var count int
func main() {
lock := &sync.RWMutex{}
for i := 0; i < 5; i++ {
go writeFile("", lock)
go readFile(lock)
}
time.Sleep(2e9)
}
// 读文件并输出
func readFile(lock *sync.RWMutex) {
lock.RLock()
data, err := ioutil.ReadFile(`D://test.txt`)
if err != nil {
fmt.Println("File reading error", err)
return
}
fmt.Println("文件读取内容为:"+ string(data))
lock.RUnlock()
}
// 写文件
func writeFile(s string, lock *sync.RWMutex) {
lock.Lock()
str := []byte( s + strconv.Itoa(count))
// 以追加模式打开文件,当文件不存在时生成文件
txt, err := os.OpenFile(`D://test.txt`, os.O_APPEND|os.O_CREATE, 0666)
defer txt.Close()
if err != nil {
panic(err)
}
// 写入文件
n, err := txt.Write(str)
// 当 n != len(b) 时,返回非零错误
if err == nil && n != len(str) {
println(n)
panic(err)
}
fmt.Printf("文件写入 %d \n", count)
count++
lock.Unlock()
} |
2.2Atomic 原子操作
比锁更轻量级,原子操作由底层硬件支持,而锁则由操作系统提供的API实现
CAS操作的优势是,可以在不形成临界区和创建互斥量的情况下完成并发安全的值替换操作。
加减法
只有Add方法,对于减法可以通过传递负数实现
func main() {
var i int32 = 1
atomic.AddInt32(&i, 1)
fmt.Println(i)
atomic.AddInt32(&i, -1)
fmt.Println(i)
} |
交换
交换j和k的值
func main() {
var j int32 = 1
var k int32 = 2
j_old := atomic.SwapInt32(&j, k)
fmt.Println("old,new:", j_old, j)
} |
比较交换
若是a,b的值不相等 把b的值交换给a,且b自身值不变
func main() {
var a int32 = 1
var b int32 = 2
var c int32 = 3 // 比较a,b的值,如果不相等,把b的值赋给a
atomic.CompareAndSwapInt32(&a, a, b)
atomic.CompareAndSwapInt32(&b, b, c)
fmt.Println("a, b, c:", a, b, c)
} |
加载
把x的值加载到y上,且对x值不会进行任何读写操作
func main() {
var x int32 = 100
y := atomic.LoadInt32(&x)
fmt.Println("x, y:", x, y)
} |
储存
加载操作的逆操作,把y的值赋给x,且对x值不会进行任何读写操作
func main() {
var x int32
var y int32= 100
atomic.StoreInt32(&x, atomic.LoadInt32(&y))
fmt.Println("x, y:", x, y)
} |
原子类型
atomic.Value 类型是开箱即用的,我们声明一个该类型的变量(以下简称原子变量)之后就可以直接使用了。这个类型使用起来很简单,它只有 Store 和 Load 两个指针方法,这两个方法都是原子操作
func main() {
var v atomic.Value
v.Store(100)
fmt.Println("v:", v.Load())
} |
三.多协程通信
3.1使用缓冲通道提升性能
func test(ch chan int) {
for i := 0; i < 1000; i++ {
ch <- i
}
close(ch)
}
func main() {
start := time.Now()
ch := make(chan int,200)
go test(ch)
for i := range ch {
fmt.Println("接收到的数据:", i)
}
end := time.Now()
consume := end.Sub(start).Seconds()
fmt.Println("程序执行耗时(s):", consume)
} |
3.2单向通道及其使用
通道本身还是要支持读写的,如果某个通道只支持写入操作,那么即便数据写进去了,不能被读取也毫无意义,同理,如果某个通道只支持读取操作,不能写入数据,那么通道永远是空的,从一个空的通道读取数据会导致协程的阻塞,无法执行后续代码。
因此,Go 语言支持的单向管道,实际上是在使用层面对通道进行限制,而不是语法层面:即我们在某个协程中只能对通道进行写入操作,而在另一个协程中只能对该通道进行读取操作。从这个层面来说,单向通道的作用是约束在生产协程中只能发送数据到通道,而在消费协程中只能从通道接收数据,从而让代码遵循「最小权限原则」,避免误操作和通道使用的混乱,让代码更加稳健。
func test(ch chan<- int) {
for i := 0; i < 100; i++ {
ch <- i
}
close(ch)
} |
3.3通过 select 语句等待通道就绪
func main() {
chs := [3]chan int{
make(chan int, 1),
make(chan int, 1),
make(chan int, 1),
}
//for i := 0; i < 100; i++ {
// in := rand.Intn(3) // 随机生成0-2之间的数字
// fmt.Printf("随机索引/数: %d\n", in)
//}
index := rand.Intn(3) // 随机生成0-2之间的数字
fmt.Printf("随机索引/数值: %d\n", index)
chs[index] <- index // 向通道发送随机数字
// 哪一个通道中有值,哪个对应的分支就会被执行
select {
case a := <-chs[0]:
fmt.Println("第一个条件分支被选中:", a)
case b := <-chs[1]:
fmt.Println("第二个条件分支被选中", b)
case c := <-chs[2]:
fmt.Println("第三个条件分支被选中:", c)
default:
fmt.Println("没有分支被选中")
}
} |
3.4超时处理机制实现
Go 语言没有提供直接的超时处理机制,但我们可以借助 select 语句来实现类似机制解决超时问题,因为 select 语句的特点是只要其中一个 case 对应的通道操作已经完成,程序就会继续往下执行,而不会考虑其他 case 的情况。
func main() {
// 初始化 ch 通道
ch := make(chan int, 1)
// 初始化 timeout 通道
timeout := make(chan bool, 1)
// 实现一个匿名超时等待函数
go func() {
time.Sleep(1e9) // 睡眠1秒钟
timeout <- true
}()
// 借助 timeout 通道结合 select 语句实现 ch 通道读取超时效果
select {
case <- ch:
fmt.Println("接收到 ch 通道数据")
case <- timeout:
fmt.Println("超时1秒,程序退出")
}
} |
3.5生产者/消费者模型
func main() { //创建一个商品管道
goods := make(chan int, 20) //生产3倍数的商品
go Producer(3, goods) //生产7倍数的商品
go Producer(7, goods) //消费者消费商品
go Consumer(goods)
time.Sleep(50)
}
//生产factor倍数的序列
func Producer(factor int, out chan<- int) {
for i := 0; ; i++ {
out <- i * factor
}
}
//消费者 消费生成的队列
func Consumer(in <-chan int) {
for v := range in {
fmt.Println(v)
}
} |
四.多核CPU并行
runtime.NumCPU()获取最大逻辑Cpu数量
runtime.GOMAXPROCS()设置当前方法使用逻辑Cpu个数
func sum(seq int, ch chan int) {
defer close(ch)
sum := 0
for i := 1; i <= 100000000; i++ {
sum += i
}
fmt.Printf("子协程%d运算结果:%d\n", seq, sum)
ch <- sum
}
func main() {
// 启动时间
start := time.Now()
// 最大 CPU 核心数
cpus := runtime.NumCPU()
runtime.GOMAXPROCS(cpus)
chs := make([]chan int, cpus)
for i := 0; i < len(chs); i++ {
chs[i] = make(chan int, 1)
go sum(i, chs[i])
}
sum := 0
for _, ch := range chs {
res := <- ch
sum += res
}
// 结束时间
end := time.Now()
// 打印耗时
fmt.Printf("最终运算结果: %d, 执行耗时(s): %f\n", sum, end.Sub(start).Seconds())
} |
五.sync 包
5.1条件变量 sync.Cond
sync.Cond 主要实现一个条件变量,假设 goroutine A 执行前需要等待另外一个 goroutine B 的通知,那么处于等待状态的 goroutine A 会保存在一个通知列表,也就是说需要某种变量状态的 goroutine A 将会等待(Wait)在那里,当某个时刻变量状态改变时,负责通知的 goroutine B 会通过对条件变量通知的方式(Broadcast/Signal)来通知处于等待条件变量的 goroutine A,这样就可以在共享内存中实现类似「消息通知」的同步机制。
下面来看一个具体的示例。假设我们有一个读取器和一个写入器,读取器必须依赖写入器对缓冲区进行数据写入后,才可以从缓冲区中读取数据,写入器每次完成写入数据后,都需要通过某种通知机制通知处于阻塞状态的读取器,告诉它可以对数据进行访问
package main
import (
"bytes"
"fmt"
"io"
"sync"
"time"
)
// 数据 bucket
type DataBucket struct {
buffer *bytes.Buffer //缓冲区
mutex *sync.RWMutex //互斥锁
cond *sync.Cond //条件变量
}
func NewDataBucket() *DataBucket {
buf := make([]byte, 0)
db := &DataBucket{
buffer: bytes.NewBuffer(buf),
mutex: new(sync.RWMutex),
}
db.cond = sync.NewCond(db.mutex.RLocker())
return db
}
// 读取器
func (db *DataBucket) Read(i int) {
db.mutex.RLock() // 打开读锁
defer db.mutex.RUnlock() // 结束后释放读锁
var data []byte
var d byte
var err error
for {
//每次读取一个字节
if d, err = db.buffer.ReadByte(); err != nil {
if err == io.EOF { // 缓冲区数据为空时执行
if string(data) != "" { // data 不为空,则打印它
fmt.Printf("reader-%d: %s\n", i, data)
}
db.cond.Wait() // 缓冲区为空,通过 Wait 方法等待通知,进入阻塞状态
data = data[:0] // 将 data 清空
continue
}
}
data = append(data, d) // 将读取到的数据添加到 data 中
}
}
// 写入器
func (db *DataBucket) Put(d []byte) (int, error) {
db.mutex.Lock() // 打开写锁
defer db.mutex.Unlock() // 结束后释放写锁
//写入一个数据块
n, err := db.buffer.Write(d)
db.cond.Signal() // 写入数据后通过 Signal 通知处于阻塞状态的读取器
return n, err
}
func main() {
db := NewDataBucket()
go db.Read(1) // 开启读取器协程
go func(i int) {
d := fmt.Sprintf("data-%d", i)
db.Put([]byte(d)) // 写入数据到缓冲区
}(1) // 开启写入器协程
time.Sleep(100 * time.Millisecond)
} |
5.2sync.WaitGroup 和 sync.Once
sync.WaitGroup 类型是开箱即用的,也是并发安全的
- Add:WaitGroup 类型有一个计数器,默认值是0,我们可以通过 Add 方法来增加这个计数器的值,通常我们可以通过个方法来标记需要等待的子协程数量;
- Done:当某个子协程执行完毕后,可以通过 Done 方法标记已完成,该方法会将所属 WaitGroup 类型实例计数器值减一,通常可以通过 defer 语言来调用它;
- Wait:Wait 方法的作用是阻塞当前协程,直到对应 WaitGroup 类型实例的计数器值归零,如果在该方法被调用的时候,对应计数器的值已经是 0,那么它将不会做任何事情。
func add_num(a, b int, deferFunc func()) {
defer func() {
deferFunc()
}()
c := a + b
fmt.Printf("%d + %d = %d\n", a, b, c)
}
func main() {
var wg sync.WaitGroup
wg.Add(10)
for i := 0; i < 10; i++ {
go add_num(i, 1, wg.Done)
}
wg.Wait()
} |
sync.Once 类型也是开箱即用和并发安全的,其主要用途是保证指定函数代码只执行一次,类似于单例模式,常用于应用启动时的一些全局初始化操作。它只提供了一个 Do 方法,该方法只接受一个参数,且这个参数的类型必须是 func(),即无参数无返回值的函数类型。
func dosomething(o *sync.Once) {
fmt.Println("开始")
o.Do(func() {
fmt.Println("我只执行一次!!!")
})
fmt.Println("结束")
}
func main() {
o := &sync.Once{}
for i:=0; i<10000 ;i++ {
go dosomething(o)
}
time.Sleep(time.Second * 1)
} |
5.3临时对象池 sync.Pool
sync.Pool 是一个临时对象池,可用来临时存储对象,下次使用时从对象池中获取,避免重复创建对象。相应的,该类型提供了 Put 和 Get 方法,分别对临时对象进行存储和获取。我们可以把 sync.Pool 看作存放可重复使用值的容器,由于 Put 方法支持的参数类型是空接口 interface{},因此这个值可以是任何类型,对应的,Get 方法返回值类型也是 interface{}。当我们通过 Get 方法从临时对称池获取临时对象后,会将原来存放在里面的对象删除,最后再返回这个对象,而如果临时对象池中原来没有存储任何对象,调用 Get 方法时会通过对象池的 New 字段对应函数创建一个新值并返回(这个 New 字段需要在初始化临时对象池时指定,否则对象池为空时调用 Get 方法返回的可能就是 nil),从而保证无论临时对象池中是否存在值,始终都能返回结果
func main() {
var pool = &sync.Pool{
New: func() interface{} {
return "Hello,World!"
},
}
pool.Put("Hello,大家伙!")
pool.Put(1)
fmt.Println(pool.Get())
fmt.Println(pool.Get())
fmt.Println(pool.Get())
} |
5.4通过 context 包提供的函数实现多协程之间的协作
通过 withXXX 方法返回一个从父 Context 拷贝的新的可撤销子 Context 对象和对应撤销函数 CancelFunc,CancelFunc 是一个函数类型,调用它时会撤销对应的子 Context 对象,当满足某种条件时,我们可以通过调用该函数结束所有子协程的运行,主协程在接收到信号后可以继续往后执行。
func AddNum(a *int32, b int, deferFunc func()) { defer func() { deferFunc() }() for i := 0; ; i++ { curNum := atomic.LoadInt32(a) newNum := curNum + 1 time.Sleep(time.Millisecond * 200) //把num+1的值赋给num if atomic.CompareAndSwapInt32(a, curNum, newNum) { fmt.Printf("number当前值: %d [%d-%d]\n", *a, b, i) break } else { //fmt.Printf("The CAS operation failed. [%d-%d]\n", b, i) } } }
func main() { total := 10 var num int32 fmt.Printf("number初始值: %d\n", num) fmt.Println("启动子协程...") ctx, cancelFunc := context.WithCancel(context.Background()) for i := 0; i <1000; i++ { go AddNum(&num, i, func() { //如果num值等于10 执行cancelFunc(),即退出函数 if atomic.LoadInt32(&num) == int32(total) { cancelFunc() } }) } <- ctx.Done() fmt.Println("所有子协程执行完毕.") } |
WithTimeout 分别比 WithCancel 多了一个timeout 时间参数,表示子 Context 存活的最长时间,如果超过了该时间,会自动撤销对应的子 Context。相应的,在调用 <-cxt.Done() 等待子协程执行结束时,如果没有调用 cancelFunc 函数的话它们会等待过期时间到达自动关闭,不过我们通常还是会主动调用 cancelFunc 函数以便更好的控制程序运行。
ctx, cancelFunc := context.WithTimeout(context.Background(), 10 * time.Second) |