基础知识
go init()
init()
函数会在包被初始化后自动执行,并且在main()
函数之前执行,但是需要注意的是init()
以及main()
函数都是无法被显式调用的。
那么init()
是不是最先执行的呢? 答案是否定的,首先,在他之前会进行全局变量的初始化;
当我们导入其他包时,会先初始化导入的包,而初始化包时,会先加载全局变量,而后从上到下加载init()
函数,当被导入的包的init()
函数执行完毕后,执行调用方的全局变量加载,init()
函数的顺序加载,之后执行main()
函数。
指针
Go 语言为程序员提供了控制数据结构的指针的能力;但是,你不能进行指针运算。通过给予程序员基本内存布 局,Go 语言允许你控制特定集合的数据结构、分配的数量以及内存访问模式,这些对构建运行良好的系统是非常重要的:指针对于性能的影响是不言而喻的,而如果你想要做的是系统编程、操作系统或者网络应用,指针更是不可或缺的一部分。
一个指针变量可以指向任何一个值的内存地址 它指向那个值的内存地址,在 32
位机器上占用 4
个字节,在 64
位机器上占用 8
个字节,并且与它所指向的值的大小无关。当然,可以声明指针指向任何类型的值来表明它的原始性或结构性;你可以在指针类型前面加上 号(前缀)来获取指针所指向的内容,这里的 号是一个类型更改器。使用一个指针引用一个值被称为间接引用。
在函数调用时,像切片(slice)、字典(map)、接口(interface)、通道(channel)这样的引用类型都是默认使用引用传递(即使没有显式的指出指针)。
常量
常量使用关键字 const 定义,用于存储不会改变的数据。
在 Go 语言中,你可以省略类型说明符 [type],因为编译器可以根据变量的值来推断其类型。
- 显式类型定义:
const b string = "abc"
- 隐式类型定义:
const b = "abc"
常量的值必须是能够在编译时就能够确定的;你可以在其赋值表达式中涉及计算过程,但是所有用于计算的值必须在编译期间就能获得。
因为在编译期间自定义函数均属于未知,因此无法用于常量的赋值,但内置函数可以使用,如:len() 常量还可以用作枚举:
const (
Unknown = 0
Female = 1
Male = 2
)
关键字
break default func interface select
case defer go map struct
chan else goto package switch
const fallthrough if range type
continue for import return var
append bool byte cap close complex complex64 complex128 uint16
copy false float32 float64 imag int int8 int16 uint32
int32 int64 iota len make new nil panic uint64
print println real recover string true uint uint8 uintptr
New和Make
new
new 是go 语言内置的函数,他的函数签名如下
fun new(Type) *Type
Goroutine Channel 的使用环境
什么场景使用 channel 合适
- 通过全局变量加锁同步来实现通讯,并不利于多个协程对全局变量的读写操作
- 加锁虽然可以解决 goroutine 对全局变量的抢占资源问题,但是影响性能
- channel 进行协程 goroutine 间的通信
操作系统中 线程和 goroutine 的关系
- 操作系统线程对应用户态多个 goroutine
- go 程序可以同时使用多个操作系统线程
- goroutine 和 OS 线程是多对多的关系 m:n
go 语言的并发模型是 CSP (Communicating Sequential Process),提倡通过通信共享内存 而不是通过共享内存而实现通信,引出了 channel
通道 channel 使用实例
for range 从通道中取值,通道关闭时 for range 退出
// channel练习 go for range从chan中取值
ch1 := make(chan int)
ch2 := make(chan int)
// 开启goroutine 把0-100写入到ch1通道中
go func() {
for i := 0; i < 100; i++ {
ch1 <- i
}
close(ch1)
}()
// 开启goroutine 从ch1中取值,值的平方赋值给 ch2
go func() {
for {
i,ok := <-ch1 //通道取值后 再取值 ok = false
if ok {
ch2 <- i*i
}else {
break
}
}
close(ch2)
}()
// 主goroutine 从ch2中取值 打印输出
// for x := chan 有值取值,通道关闭时跳出goroutine
for i :=range ch2{
fmt.Println(i)
}
channel 升级,单通道,只读通道和只写通道
func counter(in chan<- int) {
defer close(in)
for i := 0; i < 100; i++ {
in <- i
}
}
func square(in chan<- int, out <-chan int) {
defer close(in)
for i := range out {
in <- i * i
}
}
func output(out <-chan int) {
for i:=range out{
fmt.Println(i)
}
}
// 改写成单向通道
func main() {
ch1 := make(chan int)
ch2 := make(chan int)
go counter(ch1)
go square(ch2, ch1)
output(ch2)
}
goroutine work pool,可以防止 goroutine 暴涨或者泄露
//使用work pool 防止goroutine的泄露和暴涨
func worker(id int, jobs <-chan int, results chan<- int) {
for j := range jobs {
fmt.Printf("worker:%d start job:%d\n", id, j)
time.Sleep(time.Second)
fmt.Printf("worker:%d end job:%d\n", id, j)
results <- j * 2
}
}
func main() {
jobs := make(chan int, 100)
results := make(chan int, 100)
// 开启3个goroutine
for w := 1; w <= 3; w++ {
go worker(w, jobs, results)
}
// 5个任务
for j := 1; j <= 5; j++ {
jobs <- j
}
close(jobs)
// 输出结果
for a := 1; a <= 5; a++ {
<-results
}
}
goroutine 使用 select case 多路复用,满足我们同时从多个通道接收值的需求
//使用select语句能提高代码的可读性。
//可处理一个或多个channel的发送/接收操作。
//如果多个case同时满足,select会随机选择一个。
//对于没有case的select{}会一直等待,可用于阻塞main函数。
ch := make(chan int, 1)
go func() {
for i := 0; i < 10; i++ {
select {
case x := <-ch:
fmt.Println(x)
case ch <- i:
}
}
}()
goroutine 加锁 排它锁 读写锁
var x int64
var wg sync.WaitGroup
//添加互斥锁
var lock sync.Mutex
func main() {
wg.Add(2)
go add()
go add()
wg.Wait()
fmt.Println(x)
}
func add() {
for i := 0; i < 5000; i++ {
lock.Lock() //加锁
x = x + 1
lock.Unlock() //解锁
}
wg.Done()
}
GMP 模型
进程
在程序启动时,操作系统会给该程序分配一块内存空间,对于程序但看到的是一整块连续的储存空间,简称虚拟储存空间,落实到操作系统内核则是一块一块的内存碎片的东西,为了是节省内核空间,方便对内存管理,对于这边内存空间,又划分为用户空间与内核空间,用户空间只用于用户程序的执行,若要执行各种 io 操作,就会通过系统调用等进入内核空间进行操作 .每个进程都有自己的 pid,可以通过 ps 命令查某个进程的 pid,进入/proc/ 可以查看该进程的详细信息
线程
线程是进程的执行单元,一个进程可以包含多个线程,只有拥有了线程的进程才会被 cpu 执行,所以一个进程至少有一个主进程由于多个线程可以共享一个进程的内存空间,切换虚拟地址空间的开销巨大,为什么进程切换需要开销巨大:简单理解 就是因为进程切换要保存的线程太多如寄存器,栈,代码段执行位置,而线程切换只需要上下文切换,保存线程执行的上下文就行.线程的切换只需要保存线程的执行现场,保存在该线程的栈里,CPU 把栈指针,指令寄存器的值指向下一个线程 ,相比线程更加轻量级可以理解为 进程面向内存分配管理,线程主要面向 CPU 调度
协程
虽然线程比进程更轻量级,但是每个线程依然占有 1M 左右的空间,在高并发场景下 非常吃机器内存,比如构建一个 http 服务器,如果每一次请求分配一个线程,请求数量增 容易 OOM ,而线程切换的开销也很大,同时线程的创建和销毁都是系统开销,因此用内核来做,解决的方法 可以通过线程池或协程解决
协程是用户态,比如线程更加轻量级,操作系统对其没有任何感知,之所以没有感知,是由于处于协程的用户栈感知的服务,是由用户创建而非操作系统
如一个进程可拥有以有多个线程一样,一个线程也可以拥有多个协程。协程之于线程如同线程之于 cpu,拥有自己的协程队列,每个协程拥有自己的栈空间,在协程切换时候只需要保存协程的上下文,开销要比内核态的线程切换要小很多。
定义
Goroutine 的并发编程模型基 于 GMP 模型,简要解释一下 GMP 的含义:
G:表示 goroutine,每个 goroutine 都有自己的栈空间,定时器,初始化的栈空间在 2k 左右,空间会随着需求增长
M:抽象化代表内核线程,记录内核线程栈信息,当 goroutine 调度到线程时,使用该 goroutine 自己的栈信息。
P:代表调度器,负责调度 goroutine,维护一个本地 goroutine 队列,M 从 P 上获得 goroutine 并执行,同时还负责部分内存的管理。
模型
M 代表一个工作线程,在 M 上有一个 P 和 G,P 是绑定到 M 上的,G 是通过 P 的调度获取的,在某一时刻,一个 M 上只有一个 G(g0 除外)。在 P 上拥有一个 G 队列,里面是已经就绪的 G,是可以被调度到线程栈上执行的协程,称为运行队列。
接下来看一下程序中 GMP 的分布。
每个进程都有一个全局的 G 队列,也拥有 P 的本地执行队列,同时也有不在运行队列中的 G。如正处于 channel 的阻塞状态的 G,还有脱离 P 绑定在 M 的(系统调用)G,还有执行结束后进入 P 的 gFree 列表中的 G 等等,接下来列举一下常见的几种状态。
状态汇总
G 状态
G 的主要几种状态:
本文基于 Go1.13,具体代码见 src/runtime/runtime2.go
_Gidle:刚刚被分配并且还没有被初始化,值为 0,为创建 goroutine 后的 默认值
_Grunnable: 没有执行代码,没有栈的所有权,存储在运行队列中,可能在某个 P 的本地队列或全局队列中(如上图)。
_Grunning: 正在执行代码的 goroutine,拥有栈的所有权(如上图)。
_Gsyscall:正在执行系统调用,拥有栈的所有权,与 P 脱离,但是与某个 M 绑定,会在调用结束后被分配到运行队列(如上图)。
_Gwaiting:被阻塞的 goroutine,阻塞在某个 channel 的发送或者接收队列(如上图)。
_Gdead: 当前 goroutine 未被使用,没有执行代码,可能有分配的栈,分布在空闲列表 gFree,可能是一个刚刚初始化的 goroutine,也可能是执行了 goexit 退出的 goroutine(如上图)。
_Gcopystac:栈正在被拷贝,没有执行代码,不在运行队列上,执行权在
_Gscan : GC 正在扫描栈空间,没有执行代码,可以与其他状态同时存在
P 的状态
_Pidle :处理器没有运行用户代码或者调度器,被空闲队列或者改变其状态的结构持有,运行队列为空
_Prunning :被线程 M 持有,并且正在执行用户代码或者调度器(如上图)
_Psyscall:没有执行用户代码,当前线程陷入系统调用(如上图)
_Pgcstop :被线程 M 持有,当前处理器由于垃圾回收被停止
_Pdead :当前处理器已经不被使用
M 的状态
自旋线程:处于运行状态但是没有可执行 goroutine 的线程(如下图),数量最多为 GOMAXPROC,若是数量大于 GOMAXPROC 就会进入休眠。
非自旋线 程:处于运行状态有可执行 goroutine 的线程。
调度场景
Channel 阻塞:当 goroutine 读写 channel 发生阻塞时候,会调用 gopark 函数,该 G 会脱离当前的 M 与 P,调度器会执行 schedule 函数调度新的 G 到当前 M。可参考上一篇文章 channel 探秘。
系统调用:当某个 G 由于系统调用陷入内核态时,该 P 就会脱离当前的 M,此时 P 会更新自己的状态为 Psyscall,M 与 G 互相绑定,进行系统调用。结束以后若该 P 状态还是 Psyscall,则直接关联该 M 和 G,否则使用闲置的处理器处理该 G。
系统监控:当某个 G 在 P 上运行的时间超过 10ms 时候,或者 P 处于 Psyscall 状态过长等情况就会调用 retake 函数,触发新的调度。
主动让出:由于是协作式调度,该 G 会主动让出当前的 P,更新状态为 Grunnable,该 P 会调度队列中的 G 运行。
总结
Go 语言中通过 GMP 模型实现了对 CPU 和内存的合理利用,使得用户在不用担心内存的情况下体验到线程的好处。虽说协程的空间很小,但是也需要关注一下协程的生命周期,防止过多的协程滞留造成 OOM。最近遇到了线上的 OOM 问题,等待下期一起分析