第三章

go的特点

一次编码 一次编译 不需要运行环境 没有虚拟化损失 不需要自行处理 GC 面向对象 非常易用的并发能力
C X X X X X
C++ X X X X
Java X X X
JavaScript O X X
Go X

查看从代码到SSA中间码的整个过程

1
$env:GOSSAFUNC="main"
1
export GOSSAFUNC=main
1
go build

查看 Plan9 汇编代码

1
go build -gcflags -S main.go

使用 Modules

1
go get github.com/Jeffail/tunny 
1
go get github.com/Jeffail/tunny@0.1.3 #指定版本

使用goproxy.cn作为代理

1
go env -w GOPROXY=https://goproxy.cn,direct

go.mod 文件追加

1
replace github.com/Jeffail/tunny => xxx/xxx # 替代为本地的路径,而不是到远程拉取

go vender 缓存到本地

执行命令”go mod vendor”会将项目依赖的包下载到本地并保存在项目的vendor目录下。

1
go mod vendor # 把依赖的包一次性缓存到本地
1
go build -mod vendor # 执行本地缓存的包,不会去远程拉取

创建 Go Module

1
go mod init github.com/imooc/moody

个人

go的runtime

image-20230806232618671

image-20230806232450975

image-20230806232509495

image-20230806233245351

runtime.a永远会一起链接进可执行程序

g0是第一个启动的协程,g0是为了调度协程而产生的协程。随后创建一个主协程,执行用户的main方法。

image-20230807130304611

GO是面向对象吗?

——官方回答:**”Yes and No”**

  • Go允许OO的编程风格
  • Go的Struct可以看作其他语言的Class
  • Go缺乏其他语言的继承结构(平时用的“继承方法”那是另一回事)
  • Go的接口与其他语言有很大差异

Go的继承

  • Go并没有继承关系
  • 所谓Go的继承只是组合
  • 组合中的匿名字段,通过语法糖达成了类以继承的效果

总结

◆Go没有对象、没有类、没有继承
◆Go通过组合匿名字段来达到类似继承的效果
◆通过以上手段去掉了面向对象中复杂而冗余的部分
◆保留了基本的面向对象特性

第四章

基本类型大小

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
package main

import (
"fmt"
"unsafe"
)

func main(){
i := 1234 // golang中int(和uint)的大小与系统有关,在64位系统上,其大小为8字节
j:= int32(1)
f:=float32(3.141)
bytes := [5]byte{'h','e','l','l','o'}
primes := [4]int{2,3,5,7}
p:= &primes

r:= rune(666)


fmt.Println(unsafe.Sizeof(i))
fmt.Println(unsafe.Sizeof(j))
fmt.Println(unsafe.Sizeof(f))
fmt.Println(unsafe.Sizeof(bytes))
fmt.Println(unsafe.Sizeof(primes))
fmt.Println(unsafe.Sizeof(p))
fmt.Println(unsafe.Sizeof(r))
fmt.Println(unsafe.Sizeof("你好"))

}

空结构体在内存中有地址而长度为0,所有的空结构体对象的地址都是一样的。(除非被嵌套在其它结构体中)

runtime-malloc.go:

image-20230807141527937

字符串的底层是一个结构体,任何字符串实例的sizeof都是一样的(16字节):

image-20230807143919396

字符串转StringHeader

1
2
3
4
5
6
7
8
9
10
11
12
13
func main() {

str := "你a"

for _, s := range str {
fmt.Printf("unicode: %c %d %T\n", s, s, s)
}

for i := 0; i < len(str); i++ {
fmt.Printf("ascii: %c %d %T\n", str[i], str[i], str[i])
}

}

字符串的两种遍历

1
2
3
4
5
6
7
8
9
10
str := "快乐 everyday"

str1 := str[3:5]

for _, s := range str1{ // range可以自动解码utf-8
fmt.Printf("unicode: %c %d %T\n", s,s)
}

for i:=0;i<len(str1) ;i++ { // 下标访问时不会解码
fmt.Printf("ascii: %c %d %T\n", str[i], str[i])

字符串的访问
◆对字符串使用len方法得到的是字节数不是字符数
◆对字符串直接使用下标访问,得到的是字节
◆字符串被range遍历时,被解码成rune类型的字符
◆UTF-8编码解码算法位于runtime/utf8.go

使用utf-8变长编码的字符串时,怎么切分含中英文的字符串呢?以取前三个字符为例:

image-20230807144655482

切片的三种创建方式

1
2
3
arr[0:3] or slice[0:3]
slice := []int{1, 2, 3}
slice := make([]int, 10)

切片的sizeof总是24(比字符串多一个cap)

image-20230807165543546

image-20230807165244738

比如a=[1,2],然后append(a,1,2,3,4),此时期望容量大于原容量的两倍。

map的底层结构:

image-20230807221723450

unsafe.Pointer为万能指针,指向任意对象。

非溢出桶的数量为2^B。桶内的元素,其key的哈希值相同。通过种子(hash0)的到一个值,对2^B取余得到桶号。tophash的计算如下(取最前的8位):

image-20230807231714812

image-20230807232153844

tophash、keys和elems是三个数组。overflow指向溢出桶。

如果tophash碰撞得比较厉害,就继续顺序往下找(包括到溢出桶去找)。

总结:
◆Go语言使用拉链实现了hashmap
◆每一个桶中存储键哈希的前8位
◆桶超出8个数据,就会存储到溢出桶中

map的扩容

  • map溢出桶太多时会导致严重的性能下降

  • runtime.mapassign(0可能会触发扩容的情况:

    • 装载因子超过6.5(平均每个槽6.5个ky)
    • 使用了太多溢出桶(溢出桶成了一条链表,超过了普通桶)
image-20230807233207132

步骤1

创建一组新桶
oldbuckets指向原有的桶数组
buckets指向新的桶数组
map标记为扩容状态

image-20230807233400968

步骤2

将所有的数据从旧桶驱逐到新桶
采用渐进式驱逐
每次操作一个旧桶时,将旧桶数据驱逐到新桶
读取时不进行驱逐,只判断读取新桶还是引旧桶

image-20230807233455928

B的值加1,某个旧桶的数据会被驱逐到最多两个新桶中(根据新桶号)

步骤3

所有的旧桶驱逐完成后,oldbuckets回收

map采用的是增量扩容,它通过每一次的 map 操作行为去分摊总的一次性动作。(详细自行了解)

map的并发问题

提前总结
◆map在扩容时会有并发问题
◆sync.Map使用了两个map,分离了扩容问题
◆不会引发扩容的操作(查、改)使用read map
◆可能引发扩容的操作(新增)使用dirty map

如果在程序运行中,发生对一个map出现并发冲突,那么会直接爆异常。也就是非并发安全的map选择了干脆禁止同时读写(读写分离)。map的并发问题主要集中在扩容时的影响

map并发问题解决方案

  • 给map加锁(mutex)
  • 使用sync.Map

给map加锁的话,会严重影响程序的并发性,go为我们提供了sync.Map。(适用于追加少。如果经常追加那么其实和map加锁差不多)

image-20230808010204157

万能指针是entry的唯一成员。amended表示“追加”,是一个布尔值。上图演示了把a:A改成a:AAA的过程。

image-20230808010836693

此过程如下:

  1. read-map中找不到key为d
  2. amended置为true,并给dirty加锁(图中mu的作用)
  3. 在dirty追加数据,完毕后释放锁

image-20230808011109170

此过程如下:

  1. read-map中找不到key为d
  2. 发现amendedtrue,去dirty查找
  3. 在dirty找到了数据,然后给misses未命中)加1
  4. 如果misses达到了len(dirty),则作dirty提升。

image-20230808011359279image-20230808011456410

misses置0,amendedfalse。当再次需要dirty时,才会重建dirty。

正常通过read-map进行删除时没问题的。问题出在追加后删除,关键在于提升dirty时的问题。

image-20230808012331592image-20230808012349707

Go隐式接口特点

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
package main

import "fmt"

type taxi struct {
}

func (t taxi) Drive() {
fmt.Println("Drive taxi")
}

func (t taxi) MakeMoney() {
fmt.Println("Make Money")
}

type Car interface {
Drive()
}

type MoneyMaker interface {
MakeMoney()
}

func main() {

}

image-20230808133919281

iface记录了“类型”与“数据”。只有当这两个属性都为nil时,接口才为nil。

空接口的底层跟普通接口不一样,空接口的底层是eface结构体。

image-20230809154032129

当使用结构体作为方法的receiver时,会自动再生成一个以对应结构体的指针作为recevier的方法(如下图),而反之则没有,在实现接口时要注意这一点。

image-20230809154309595

空指针

  • nil是空,并不一定是”空指针”
  • nil是6种类型的”零值”
  • 每种类型的nil是不同的,无法比较

image-20230809162429720

可见不包括结构体,这表明任何结构体实例都不等于nil(即使是空结构体)。

以上定义在builtin包里,这个包表示“内置”,定义了许多我们熟知的类型:

image-20230809162651901

结构体和指针实现接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
type Car interface {
Drive()
}

type truck struct {
}

func (t *truck) Drive() {

}

func main() {
var a Car = truck{}
fmt.Println(reflect.TypeOf(a))
}

变量对齐

1
2
3
4
5
6
7
8
9
10
11
12
13
14
type Args struct {
num1 int32
num2 int32
}

type Flag struct {
num1 int16
num2 int32
}

func main() {
fmt.Println(unsafe.Sizeof(Args{}))
fmt.Println(unsafe.Sizeof(Flag{}))
}
1
2
3

unsafe.Alignof(Args{}) // 8
unsafe.Alignof(Flag{}) // 4
1
2
3
4
5
6
7
8
func main() {
fmt.Printf("bool size:%d align: %d\n", unsafe.Sizeof(bool(true)), unsafe.Alignof(bool(true)))
fmt.Printf("byte size:%d align: %d\n", unsafe.Sizeof(byte(0)), unsafe.Alignof(byte(0)))
fmt.Printf("int8 size:%d align: %d\n", unsafe.Sizeof(int8(0)), unsafe.Alignof(int8(0)))
fmt.Printf("int16 size:%d align: %d\n", unsafe.Sizeof(int16(0)), unsafe.Alignof(int16(0)))
fmt.Printf("int32 size:%d align: %d\n", unsafe.Sizeof(int32(0)), unsafe.Alignof(int32(0)))
fmt.Printf("int64 size:%d align: %d\n", unsafe.Sizeof(int64(0)), unsafe.Alignof(int64(0)))
}
  • Go中每一个变量都有自己的对齐系数

    对齐系数的含义是:变量的内存地址必须被对齐系数整除
    如果对齐系数为4,表示变量内存地址必须是4的倍数

image-20230809164302926

字长(Word length)是指计算机处理数据的基本单元的位数,它表示计算机能够一次处理的二进制位数或字节数。以下以字长为64位(即八字节)为例。

image-20230809170335767

假如非字长对齐,那么内存的原子性与效率受到影响

对齐系数:(一般等于自身大小)

image-20230809170548445

string的大小为16,但对齐系数为8。每个成员的偏移量是自身大小与其对齐系数较小值的倍数。

结构体既需要内部对齐,又需要外部填充对齐

image-20230809170714809image-20230809170731018

注意结构体中成员的顺序在内存中是严格的。

结构体的对齐系数是其成员的最大对齐系数

空结构体

  1. 空结构体单独出现时,地址为zerobase
  2. 空结构体出现在结构体中时,地址跟随前一个变量
  3. 空结构体出现在结构体末尾时,需要补齐字长
1
2
3
4
5
6
type Demo struct {
a bool
z struct{}
c int16
b string
}
image-20230809171101156
1
2
3
4
5
6
type Demo struct {
a bool
c int16
b string
z struct{}
}
image-20230809171245130

这是为了防止内存分配的下一个变量的地址与z撞车。

字长对齐

  • 对于特定系统,也有系统对齐系数,一般为系统字长
  • 变量要尽量放置在一个系统字长里
1
2
3
4
5
6
7
var a bool
var b int16
var c int

fmt.Printf("%p\n", &a)
fmt.Printf("%p\n", &b)
fmt.Printf("%p\n", &c)

第五章

5-2节代码

1
2
3
4
5
6
7
8
9
func do3() {
fmt.Println("dododo")
}
func do2() {
do3()
}
func do1() {
do2()
}

协程本质

关于协程,我觉得这篇协程的原理以及与线程的区别 - rhyme - 博客园 (cnblogs.com)讲挺好的。

协程
◆协程就是将一段程序的运行状态打包,可以在线程之间调度
◆将生产流程打包,使得流程不固定在生产线上
◆协程并不取代线程,协程也要在线程上运行
◆线程是协程的资源,协程使用线程这个资源

协程的优势
◆资源利用
◆快速调度
◆超高并发

协程的底层结构(因为协程本身就是go程序去管)

image-20230809203530281

  • runtime中,协程的本质是一个g结构体
  • stack:堆栈地址
  • gobuf:目前程序运行现场
  • atomicstatus:协程状态

下图展示了g结构体的一些关键部分:

image-20230809202942888

每个goroutine有自己的栈空间。

通过sp得知执行到哪个函数了,通过pc得知执行到哪一行了。

m结构体:(线程归内核管,只记录了线程的信息)(比如当前执行的协程为哪一个)

image-20230809203556171

image-20230809203421271

协程工作

image-20230810172509774

过程如下:

  1. 线程一开始进入的是g0的协程栈,执行其schedule方法
  2. schedule找到一个可执行的协程g,传给execute函数
  3. execute函数做了一些初始化工作,随后进入gogo函数
  4. gogo函数是汇编实现的,它向g中人为地在函数栈中插入一段goexit函数,这是为了最后返回到此函数中
  5. gogo函数随后根据sp和pc,从协程的某个位置继续执行
  6. 返回到goexit函数后,该函数将线程的调用栈切换回g0

注意上述这个过程始终是运行在线程上的。为了适应多核,后来又加入了多线程循环。

image-20230810172721279

==runnable queue==(可用协程队列)需要加锁。

总结-线程循环
◆操作系统并不知道Goroutine的存在
◆操作系统线程执行一个调度循环,顺序执行Goroutine
◆调度循环非常像线程池

问题
◆协程顺序执行,无法并发
◆多线程并发时,会抢夺协程队列的全局锁

GMP调度模型

GMP调度模型旨在解决上述第二个问题。

GMP对应了g, m, p三个结构体。p是processer的简称,p结构体内含有指向其服务的m的指针,以及一个储存g结构体指针的本地队列。m可以无锁从本地队列中取出可用协程。runnext指向队列中的下一个可用协程。

image-20230810175427210

image-20230810175134708 image-20230810175705388

当P中的协程用完时,会到全局队列中批量获取协程,该过程仍需要获取锁。

image-20230810175108875

任务窃取:如果M在本地或者全局队列中都找不到可用G,去别的P中“偷”,这样增强了线程的利用率。

新建协程
◆随机寻找一个P
◆将新协程放入P的runnext(插队)(优先执行新建协程)
◆若P本地队列满,放入全局队列

协程并发

◆如果协程顺序执行,会有饥饿问题
◆协程执行中间,将协程挂起,执行其他协程
◆完成系统调用时挂起,也可以主动挂起
◆防止全局队列饥饿,本地队列随机抽取全局队列

image-20230810181103251

这里放回的是本地队列,但这样可能会引起全局队列中的协程饥饿。

go底层源码的解决方法是:每从P结构体的队列拿61个协程以后,就到全局队列去拿一个。

image-20230810181518763

切换时机:

image-20230810181301271

系统调用结束时会挂起。

主动挂起不是说自己调用gopark函数(注意小写字母开头)。像Sleep这样的函数底层就调用了gopark。

抢占式调度

基于协作的抢占式调度

问题——如果程序:

◆永远都不主动挂起
◆永远都不系统调用

那么便不能及时切换。考虑到有什么过程会在程序中被经常调用,go为morestack函数赋予了其他功能

image-20230810183044264

调用函数时,需要先通过morestack函数来检查协程栈是否充足。同时,morestack会检查协程是否处于被标记抢占(系统监控器负责)的状态,如果是,回到schedule()。

image-20230810183324278

image-20230810183436539

问题:以下代码无法切换协程。

1
2
3
4
5
6
7
func s() {

i := 0
for true {
i++
}
}

go将SIGURG(紧急信号)用作调度用途。

image-20230810193831160 image-20230810193954289 image-20230810182718456

无限开启协程

1
2
3
4
5
6
7
func main() {
go func(i int) {
fmt.Println(i)
time.Sleep(time.Second)
}(i)
}

利用 channel 的缓存区

1
2
3
4
5
6
7
8
9
10
11
12
13
func main() {
ch := make(chan struct{}, 30000)
for i := 0; i < math.MaxInt32; i++ {
ch <- struct{}{}
go func(i int) {
log.Println(i)
time.Sleep(time.Second)
<-ch
}(i)
}
time.Sleep(time.Hour)
}

此代码会因为并发操作数量过多而在运行中爆panic。

协程池

1
2
go env -w GOPROXY=https://goproxy.cn,direct
go get github.com/Jeffail/tunny
1
2
3
4
5
6
7
8
9
10
11
12
13
func main() {
pool := tunny.NewFunc(3000, func(i interface{}) interface{} {
log.Println(i)
time.Sleep(time.Second)
return nil
})
defer pool.Close()

for i := 0; i < 1000000; i++ {
go pool.Process(i)
}
time.Sleep(time.Second * 4)
}

老师并不推荐用协程池(因为go的协程调度已经够池化了。◆Go语言的初衷是希望协程即用即毁,不要池化)

协程太多的问题
◆文件打开数限制
◆内存限制
◆调度开销过大

总结
◆太多的协程会给程序运行带来性能和稳定性问题
◆牺牲并发特性,利用channe|缓冲

第六章

Atomic 机制

  • CPU 级别支持的原子操作
  • X86平台:给内存加锁,再操作
  • Arm平台:先操作,如果操作失败,再重试
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
func add(p *int32) {
*p++

//*p = *p + 1

//atomic.AddInt32(p, 1)

}

func main() {
c := int32(0)
for i := 0; i < 1000; i++ {
go add(&c)
}
time.Sleep(5 * time.Second)
fmt.Println(c)
}
image-20230923140059039

atomic操作运用了硬件层面锁

image-20230923140124995

CAS

1
ok = atomic.CompareAndSwapInt32(&value, value, newValue)

在Go语言中,CAS(Compare and Swap)是一种原子操作,用于实现并发安全的内存访问。CAS操作由sync/atomic包提供,它允许你比较一个内存地址的值与旧值,并在相等时将新值写入该地址。CAS操作可以用于实现无锁的并发算法,如自旋锁、无锁队列(应用在头和尾指针的Swap中)等。

CAS操作在并发环境中通常包含以下步骤:

  1. 读取内存地址的当前值。
  2. 比较当前值与期望的旧值。如果它们相等,说明内存地址的值没有被其他线程修改,可以进行更新操作。
  3. 如果当前值与旧值相等,则将新值写入内存地址。
  4. 如果当前值与旧值不相等,则说明其他线程已经修改了内存地址的值,CAS操作失败,需要重新尝试。

CAS操作是原子的,意味着它在执行期间不会被其他线程中断。如果多个线程同时尝试执行CAS操作,只有一个线程能够成功,其他线程需要重新尝试或执行其他操作。

sema锁

image-20230923134634499

image-20230923134514184

底层仍有atomic操作,Uint32是接下去的重点。

image-20230923134537378

treap指针指着一颗平衡二叉树。二叉树的每个节点是一个sudog结构体,每个结构体内对应有一个协程(g结构体)。

image-20230923134729269

sema操作(uint32>0)
获取锁:uint32减一,获取成功
释放锁:uint32加一,释放成功

image-20230923135352816

查看semacquire1函数。sema锁的uint32值为0时,协程会加入二叉树并阻塞在以下这一步(s是当前协程的sudog)——

image-20230923135723360

总结
原子操作是一种硬件层面加锁的机制
数据类型和操作类型有限制
sema锁是runtimel的常用工具
sema经常被用作休眠队列

互斥锁

底层结构:

image-20230923150041312

image-20230923151451774

WaiterShift表示阻塞在这把锁上的协程数量,占29位。

image-20230923150340781

当锁被抢占时(即Locked为1),当前协程会尝试多次自旋(Spin),重复判断Locked,随后才去获取sema。

由于sema被配置为0,所有获取它的行为必然失败并导致进入二叉树休眠,相当于是一个等待队列。

image-20230923150537361

WaiterShift加一。当锁被释放时,再从平衡二叉树唤醒一个休眠的协程。

image-20230923151130927

被唤醒协程仍要和新来的协程继续竞争锁,这可能导致某些协程迟迟获取不到锁,造成饥饿现象。

总结
mutex正常模式:自旋加锁+sema休眠等待
mutex正常模式下,可能有锁饥饿的问题

锁饥饿

Starving位标志饥饿模式。

image-20230923151946726

总结
锁竞争严重时,互斥锁进入饥饿模式
饥饿模式没有自旋等待,有利于公平

优化经验
减少锁的使用时间(减少粒度)
善用defer确保锁的释放
image-20230923154657676

读写锁的使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
type Person struct {
mu sync.RWMutex
salary int
level int
}

func promote(p *Person) {
p.mu.Lock()
p.salary++
fmt.Println(p.salary)
p.level++
fmt.Println(p.level)
p.mu.Unlock()
}

func printPerson(p *Person) {
defer p.mu.RUnlock()
p.mu.RLock()
fmt.Println(p.salary)
fmt.Println(p.level)
}

func main() {

p := Person{level: 1, salary: 10000}

go promote(&p)
go promote(&p)
go promote(&p)

time.Sleep(time.Second)

}

waitgroup的使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
type Person struct {
mu sync.RWMutex
salary int
level int
}

func promote(p *Person, wg *sync.WaitGroup) {
p.mu.Lock()
p.salary++
fmt.Println(p.salary)
p.level++
fmt.Println(p.level)
p.mu.Unlock()
wg.Done()
}

func printPerson(p *Person, wg *sync.WaitGroup) {
defer p.mu.RUnlock()
p.mu.RLock()
fmt.Println(p.salary)
fmt.Println(p.level)
}

func main() {

p := Person{level: 1, salary: 10000}
wg := sync.WaitGroup{}
wg.Add(3)
go promote(&p, &wg)
go promote(&p, &wg)
go promote(&p, &wg)
wg.Wait()
//time.Sleep(time.Second)

}
image-20230924014422700

image-20230924014629366

noCopy标志着此类型的实例不可复制,运行时会检查此值,在违规时报异常。

image-20230924014523381

waiter代表等待的协程数,counter代表正在运行的占有WaitGroup的协程数。
调用Wait方法后,会检查counter是否为0,然后产生不同的行为(见上)。

总结
WaitGroup实现了一组协程等待另一组协程
等待的协程陷入sema并记录个数
被等待的协程计数归零时,唤醒所有sema中的协程

锁拷贝问题

  • 锁拷贝可能导致锁的死锁问题
  • 使用 vet 工具可以检测锁拷贝问题
  • vet 还能检测可能的 bug 或者可疑的构造
1
2
3
4
5
6
func main() {

m := sync.Mutex{}
n := m
fmt.Println(n)
}
1
go vet 

image-20230924021014862

RACE 竞争检测

1
2
3
4
5
6
7
8
9
10
11
12
var J int

func do() {
J++
}

func main() {

for i := 0; i < 200; i++ {
go do()
}
}
1
go build -race main.go

image-20230924020803785

RACE竞争检测

  • 发现隐含的数据竞争问题
  • 可能是加锁的建议
  • 可能是bug的提醒

dead lock 检测

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
package main

import (
sync "github.com/sasha-s/go-deadlock"
"time"
)

var J int

var M = sync.Mutex{}

func do() {
M.Lock()
J++
time.Sleep(100000000000)
M.Unlock()
}

func main() {
sync.Opts.DeadlockTimeout = time.Millisecond * 100
for i := 0; i < 200; i++ {
go do()
}
time.Sleep(10000000000000000)
}

go-deadlock检测(这是一个github开源项目)

  • 检测可能的死锁
  • 实际是检测获取锁的等待时间
  • 用来排查bug和性能问题

第七章

Channel 声明方法

  • chInt := make(chan int) // unbuffered channel 非缓冲通道
  • chBool := make(chan bool, 0) // unbuffered channel 非缓冲通道
  • chStr := make(chan string, 2) // bufferd channel 缓冲通道

Channel 基本用法

  • ch <- x // channel 接收数据 x
  • x <- ch // channel 发送数据并赋值给 x
  • <- ch // channel 发送数据,忽略接受者

错误用法

1
2
3
4
5
6
7
func main() {
ch := make(chan string)

ch <- "ping"

fmt.Println(<-ch)
}
1
2
3
4
5
6
7
8
9
func main() {
ch := make(chan string)

go func() {
ch <- "ping"
}()

fmt.Println(<-ch)
}

内存与通信

  • “不要通过共享内存的方式进行通信”
  • “而是应该通过通信的方式共享内存”
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
func watch(p *int) {
for true {
if *p == 1 {
fmt.Println("hello")
break
}
}
}

func main() {
i := 0
go watch(&i)

time.Sleep(time.Second)

i = 1

time.Sleep(time.Second)
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
func watch(c chan int) {

if <-c == 1 {
fmt.Println("hello")
}
}

func main() {
c := make(chan int)
go watch(c)

time.Sleep(time.Second)

c <- 1

time.Sleep(time.Second)
}

为什么使用channel来通信

相对于无锁

  • 避免协程竞争和数据冲突的问题

相对于加锁

  • 更高级的抽象,降低开发难度,增加程序可读性
  • 模块之间更容易解耦,增强扩展性和可维护性
image-20230901205515057

channel的底层结构

image-20230901205426930image-20230901213642355

前六行描述了一个缓存区(buf指针可能为空,表示为无缓存),后面的成员描述了两个队列。

image-20230901221426761waitq包含了sudog结构体,表示被阻塞的协程。sudog中含有发送或接受的对象的地址。

可以看到有一把互斥锁lock

互斥锁并不是排队发送/接收数据
互斥锁保护的hchans结构体本身
Channel并不是无锁的

image-20230901205454171image-20230901213721621

发送和接受

c<-关键字是一个语法糖
编译阶段,会把c<-转化为runtime.chansend1()
chansend1()会调用chansend0方法

发送情况

image-20230901221659852

若接收队列为空,才把变量拷贝进缓存中的可用缓存单元。

image-20230901221313436

总结

image-20230901221845826

接受情况

<-C关键字是一个语法糖
编译阶段,i<-c转化为runtime.chanrecv1()
编译阶段,i,ok<-c转化为runtime.chanrecv2()

image-20230901223641381 image-20230901223535109 image-20230901223728749

总结

编译阶段,<-c会转化为chanrecv()
有等待的G,且无缓存时,从G接收
有等待的G,且有缓存时,从缓存接收
无等待的G,且缓存有数据,从缓存接收
无等待的G,且缓存无数据,等待喂数据

第八章

goroutine-per-connection 编程风格

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
//TcpServer.go
package main

import (
"fmt"
"net"
)

func main() {
ln, err := net.Listen("tcp", ":8080")
if err != nil {
panic(err)
}
for {
conn, err := ln.Accept()
if err != nil {
panic(err)
}
// 每个Client一个Goroutine
go handleConnection(conn)
}
}

func handleConnection(conn net.Conn) {
defer conn.Close()
var body [4]byte
addr := conn.RemoteAddr()
for {
// 读取客户端消息
_, err := conn.Read(body[:])
if err != nil {
break
}
fmt.Printf("收到%s消息: %s\n", addr, string(body[:]))
// 回包
_, err = conn.Write(body[:])
if err != nil {
break
}
fmt.Printf("发送给%s: %s\n", addr, string(body[:]))
}
fmt.Printf("与%s断开!\n", addr)
}

IO模型

Socket

很多语言都停供了TCP函数,但是要开发者自己去留意三次握手和四次挥手太过麻烦了,所以很多系统都提供Socket作为TCP网络连接的抽象(平时用到的网络编程实际上基本都是操作Socket)。

image-20230901225708982

IO模型
IO模型指的是同时操作Socket的方案——
◆阻塞
◆非阻塞
◆多路复用

image-20230903142920702
  • 同步读写Socket时,线程陷入内核态(每个线程都会因为没有客户端数据而阻塞,阻塞就是内核态)
  • 当读写成功后,切换回用户态,继续执行
  • 优点:开发难度小,代码简单
  • 缺点:内核态切换开销大
image-20230903143107615
  • 如果暂时无法收发数据,会返回错误
  • 应用会不断轮询,直到Socket可以读写
  • 优点:不会陷入内核态,自由度高
  • 缺点:需要自旋轮询
image-20230903143153980

epoll全称event poll(有时叫事件池,poll不是池子的意思),我们监听的任务交给操作系统。我们会非阻塞地去epoll获取可读事件(的列表),从而操作对应的socket。

image-20230903165035802

  • 注册多个Socket事件
  • 调用epool,当有事件发生,返回
  • 优点:提供了事件列表,不需要查询各个Scoket
  • 缺点:开发难度大,逻辑复杂
image-20230903143403385

Go网络编程

有没有能结合阻塞模型和多路复用的方法?

image-20230903143439273

阻塞模型+多路复用
在底层使用操作系统的多路复用IO
在协程层次使用阻塞模型
阻塞协程时,休眠协程

image-20230903143806363

(协程的休眠不必进入内核态)

首先为了适应不同系统,需要对epoll进行抽象。

image-20230903160423746

epoll、IOPC、Kqueue分别是Linux、window、Mac对多路复用的系统工具

image-20230903145239020image-20230903144554556

netpoll是go的工具,epoll是其在linux的底层实现。

golang对于epoll的抽象,因为我对epoll还没了解,因此还是得后续再去看一些文章。比如 epoll在Golang的应用 - 知乎 (zhihu.com)

image-20230903170749361

image-20230903170059177image-20230903165338102

image-20230903170316260

conn.Read被阻塞是被卡在底层的WaitRead()上。

image-20230903170422509

“goroutine-per-connection编程风格”:

  1. 用主协程监听Listener
  2. 每个Conn使用一个新协程处理
  3. 结合了多路复用的性能和阻塞模型的简洁

第九章

9-1 节代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package main

func sum(a, b int) int {
sum := 0
sum = a + b
return sum
}

func main() {
a := 3
b := 5
print(
sum(a, b)
)
}
image-20230902171608399

Go的协程栈是从Go的堆内存上申请的,栈的释放也是通过GC释放的。(C语言中,栈空间和堆空间严格分开,而Go是包含关系)

栈的结构(重点)

image-20230902163034362

每个栈帧的第一个元素是上一个栈帧的基址,这样以便栈指针回退。

注意顺序,先进入sum的栈帧再进入print。在调用sum前会拷贝实参给形参(可见go是值传递的),同时预留一块内存以接收返回值。通过“返回后的指令”可以继续调研print

协程栈不够大怎么办?
◆局部变量太大(逃逸分析)
◆栈帧太多(栈扩容)

image-20230902162704591

image-20230902162906830image-20230902162843420

逃逸分析

  • 函数返回了对象的指针
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
package main

import "fmt"

type Demo struct {
name string
}

func createDemo(name string) *Demo {
d := new(Demo) // 局部变量 d 逃逸到堆
d.name = name
return d
}

func main() {
demo := createDemo("demo")
fmt.Println(demo)
}

指针逃逸:函数返回了对象的指针

空接口逃逸:
如果函数参数为interface{}
函数的实参很可能会逃逸
因为interface{}类型的函数往往会使用反射(就是在被调用的函数里可能会去分析实参的结构,而这种分析对堆对象更容易)(尤其是使用fmt包时,打印对象往往要通过反射分析其字段。可以改用log包来打印信息)

大变量逃逸:
过大的变量会导致栈空间不足
64位机器中,一般超过64KB的变量会逃逸

Go的堆结构

heapArena
Go每次申请的虚拟内存单元为64MB
最多有4,194,304个虚拟内存单元(2^20)
内存单元t也叫heapArena
所有的heapArena组成了mheap(Go堆内存)

image-20230902165050462

(2^20乘以64MB恰好就是256TB(64位机的虚拟内存空间),这是特别设计的,当然了申请太多会被操作系统给kill掉)

描述堆内存的结构体——

image-20230902165240324

image-20230902165315508

线性分配或链表分配的问题:

image-20230902172630776

分级分配思想:

image-20230902172716267

这个“级”,我们成为span。

内存管理单元mspan
根据隔离适应策略,使用内存时的最小单位为mspan
每个mspan为N个相同大小的“格子”
Go中一共有67种mspan(后续提到68是因为多了一种0级)

image-20230902172926419

在前文的mheap结构体中,可以看见mspan数组。

image-20230902173057304

每种mspan并不是一开始就分好的,而是需要时再分配。**mspan不是按级别顺序挨在一起的**,可能是分散在不同的heapArena中。

那么,在分配内存时,难道需要遍历每个heapArena去寻找合适的mspan吗?

中心索引mcentral
136个mcentral结构体,其中
68个组需要GC扫描的mspan
68个组不需要GC扫描的mspan(比如常量)

专门开辟一片内存空间作为中心索引。mcentral结构体实际上是一个链表头。

image-20230902173707749

image-20230902174020293

这样就可以到中心索引中去找最适合的mcentral,进一步找到适合的mspan了。

但是,如果一个mcentral正被一个线程操作,那么就得加锁,这样就不符合高并发场景。因此参考GMP进一步做设计——

线程缓存ncache
每个P(就是GMP的p结构体)拥有一个mcache
一个mcache拥有136个mspan,其中
68个需要GC扫描的mspan
68个不需要GC扫描的mspan

image-20230902174840085

线程直接用所对应的p结构体的mcache就好了。当mcache中某个级别的span满了以后,就会去中心索引中再换取一个。

总结

image-20230902174817311

Go的堆内存分配

Go将对象按照大小分为3种 image-20230902181844190

微小对象使用普通mcache

image-20230902181955712

mcache中,每个级别的mspan只有一个。当mpan满了之后,会从mcentral中换一个新的

mcentral中,只有有限数量的mspan,当mspan缺少时,会从neapArena开辟新的mspan

当neapArena空间不足时,向操作系统申请新的heapArena(扩容

大对象:直接从neapArena开辟0级的mspan,0级的mspan为大对象定制,可大可小。

GC垃圾回收

三种回收方式

  • 标记-清除(最简单直接,Go所使用)(标记为已删除后,下次gc时回收)
  • 标记-整理
  • 标记-复制

GC思路:找到有引用的对象,剩下的就是没有引用的

image-20230902203933121

结构体中可能还有指向其他对象的指针,因此要做可达性分析

image-20230902204036684

(上图有误,应该是BFS)对到达不了的变量做标记,此过程中必须暂停其他业务,也就是串行GC

image-20230902204200364

三色标记法

  • 黑色:有用,已经分析扫描
  • 灰色:有用,还未分析扫描
  • 白色:暂时无用

基本过程——

image-20230902204514924 image-20230902204610082 image-20230902204644693 image-20230902204658907 image-20230902204719632

解决并发问题的策略——混合标记(删除屏障+插入屏障)

  • 被删除的堆对象标记为灰色(快照标记)
  • 被添加的堆对象标记为灰色

如下图,B->C被删除,然后添加E->C,因为有删除屏障,所以C不会被误删除。

image-20230902204803765

如下图,只是添加E->C,但是E已是黑色,导致C被误清除。因此引入了插入屏障。

image-20230902204923889

GC优化

GC触发时机
系统定时触发
用户显式触发
申请内存时触发

(1)系统定时触发
sysmon定时检查
如果2分钟内没有过GC,强制触发
谨慎调整

(2)用户显式触发
用户调用runtime.GC方法
并不推荐调用

(3)申请内存时触发

image-20230902202153276

可见干预触发是不现实的。我们要做的就是尽量少在堆上产生垃圾

  • 内存池化
  • 减少逃逸
  • 使用空结构体

GC分析工具

1
go tool pprof
1
go tool trace
1
go build -gcflags=”-m”
1
GODEBUG=”gctrace=1”

执行$env:GODEBUG="gctrace=1",之后运行程序。一条打印信息如下:

image-20230902203057942

@0.011s表示此次gc发生的时刻(自程序启动后),4%表示gc占用时间在程序运行中的占比(虽说gc可以并发,但有些任务是不能并发的)。4->6->5MB表示gc前、中、后的堆内存。

gc占比超过10%时,最好去做一下优化。

第十章

cgo

1
2
3
4
5
6
7
8
9
10
11
12
13
package main

/*
int sum(int a, int b) {
return a+b;
}
*/

import "C"

func main() {
println(C.sum(1, 1))
}

10-2节代码

1
2
3
4
5
6
7
8
9
10
11
package main

import "fmt"

func main() {

defer fmt.Println("defer1")
defer fmt.Println("defer2")

fmt.Println("do something")
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
package main

import (
"fmt"
"time"
)

func dosomething() {
panic("panic1")

fmt.Println("do something2")

}

func main() {

dosomething()
fmt.Println("do something1")

time.Sleep(1 * time.Second)
}

panic基本使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
package main

import (
"fmt"
"time"
)

func dosomething() {
panic("panic1")

fmt.Println("do something2")

}

func main() {

go dosomething()
fmt.Println("do something1")

time.Sleep(1 * time.Second)
}

panic + defer

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
package main

import (
"fmt"
"time"
)

func dosomething() {

defer fmt.Println("defer2")

panic("panic1")

fmt.Println("do something2")

}

func main() {
defer fmt.Println("defer1")

dosomething()
fmt.Println("do something1")

time.Sleep(1 * time.Second)
}
  • panic在退出协程之前会执行所有已注册的defer

panic + defer + recover

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
package main

import (
"fmt"
"time"
)

func dosomething() {

defer func() {
if r := recover(); r != nil {
fmt.Println("Recovered. Error:\n", r)
}
}()
panic("panic1")

fmt.Println("do something2")

}

func main() {
defer fmt.Println("defer1")

dosomething()
fmt.Println("do something1")

time.Sleep(1 * time.Second)
}

对象到反射对象

1
2
3
4
5
6
7
8
9
func main() {
s := "moody"

stype := reflect.TypeOf(s)
fmt.Println("TypeOf s:", stype)

svalue := reflect.ValueOf(s)
fmt.Println("ValueOf s:", svalue)
}

反射对象到对象

1
2
3
4
5
6
7
8
9
10
11
12
13
func main() {
s := "moody"

stype := reflect.TypeOf(s)
fmt.Println("TypeOf s:", stype)

svalue := reflect.ValueOf(s)
fmt.Println("ValueOf s:", svalue)

s2 := svalue.Interface().(string)

fmt.Println("s2:", s2)
}

发射调用方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
package main

import (
"fmt"
"reflect"
)

func MyAdd(a, b int) int { return a + b }

func CallAdd(f func(a int, b int) int) {
v := reflect.ValueOf(f)
if v.Kind() != reflect.Func {
return
}
argv := make([]reflect.Value, 2)
argv[0] = reflect.ValueOf(1)
argv[1] = reflect.ValueOf(1)

result := v.Call(argv)

fmt.Println(result[0].Int())
}

func main() {

CallAdd(MyAdd)
}