Go语言笔记

学习内容:

Go语言笔记_第1张图片

1、编译原理

Go语言笔记_第2张图片

  • 词法与语法分析:解析源代码文件,将文件中的字符串序列转换成 Token 序列;语法分析则把 Token 序列转换成有意义的抽象语法树;
  • 类型检查:通过对整棵抽象语法树的遍历,对每个节点当前子树的类型进行验证,以保证节点不存在类型错误(检查内容如常量、类型和函数名及类型,变量的赋值和初始化,函数和闭包的主体,哈希键值对的类型,导入函数体,外部的声明);
  • 中间代码生成:并发执行将所有函数对应的抽象语法树转换成中间代码;
  • 机器码生成 :将中间代码转换成使用不同指令集架构的 CPU 架构上能够运行的二进制代码;

2、语言基础

2.1、数组、切片、Hash表

数组:数组是由相同类型元素的集合组成的数据结构
切片:动态数组,其长度并不固定,可以向切片中追加元素,它会在容量不足时自动扩容
Hash表:哈希表示的是键值对之间映射关系

2.2、函数调用、接口、反射

函数参数传递:Go语言选择了传值的方式,无论是传递基本类型、结构体还是指针,都会对传递的参数进行拷贝.

  • 传值:函数调用时会对参数进行拷贝,被调用方和调用方两者持有不相关的两份数据;
  • 传引用:函数调用时会传递参数的指针,被调用方和调用方两者持有相同的数据,任意一方做出的修改都会影响另一方。

结构体和指针:将指针作为参数传入某个函数时,函数内部会复制指针,也就是会同时出现两个指针指向原有的内存空间;应该尽量使用指针作为参数类型来避免发生数据拷贝进而影响性能。

  • 传递结构体时:会拷贝结构体中的全部内容;
  • 传递结构体指针时:会拷贝结构体指针.

接口:Java 中的类必须通过implements显式地声明实现的接口,Go语言中接口是隐式的,定义接口需要使用 interface 关键字,只能定义方法签名,不能包含成员变量。

type error interface {
	Error() string
}
#实现 error 接口,只需要实现 Error() string 方法即可
type RPCError struct {
	Code    int64
	Message string
}
func (e *RPCError) Error() string {
	return fmt.Sprintf("%s, code=%d", e.Message, e.Code)
}

interface{}:特殊的类型不是任意类型

package main

type TestStruct struct{}

func NilOrNot(v interface{}) bool {
	return v == nil
}

func main() {
	var s *TestStruct
	fmt.Println(s == nil)      // #=> true
	fmt.Println(NilOrNot(s))   // #=> false
}

$ go run main.go
true
false
调用 NilOrNot 函数时发生了隐式的类型转换,除了向方法传入参数之外,变量的赋值也会触发隐式类型转换;
转换后的变量interface{}类型不仅包含转换前的变量,还包含变量的类型信息TestStruct
实现接口的类型和初始化返回的类型不同,编译器的检查能否通过
	             结构体实现接口	结构体指针实现接口
结构体初始化变量	    通过	           不通过
结构体指针初始化变量	通过	            通过

type Duck interface {
	Quack()
}
type Cat struct{}
---------------通过----------
func (c Cat) Quack() {
	fmt.Println("meow")
}
func main() {
	var c Duck = &Cat{}
	c.Quack()
}
----------------不通过----------------
func (c *Cat) Quack() {
	fmt.Println("meow")
}
func main() {
	var c Duck = Cat{}
	c.Quack()
}
$ go build interface.go
./interface.go:20:6: cannot use Cat literal (type Cat) as type Duck in assignment:
	Cat does not implement Duck (Quack method has pointer receiver)

无论上述代码中初始化的变量 c 是 Cat{} 还是 &Cat{},使用 c.Quack() 调用方法时都会发生值拷贝:

  • 对于 &Cat{} 来说,这意味着拷贝一个新的 &Cat{} 指针,这个指针与原来的指针指向一个相同并且唯一的结构体,所以编译器可以隐式的对变量解引用(dereference)获取指针指向的结构体;
  • 对于 Cat{} 来说,这意味着 Quack 方法会接受一个全新的 Cat{},因为方法的参数是 *Cat,编译器不会无中生有创建一个新的指针;即使编译器可以创建新指针,这个指针指向的也不是最初调用该方法的结构体;

反射:reflect 实现了运行时的反射能力,能够让程序操作不同类型的对象;reflect.TypeOf 能获取类型信息、reflect.ValueOf 能获取数据的运行时表示。
Go语言笔记_第3张图片

func main() {
	i := 1
	v := reflect.ValueOf(i)
	v.SetInt(10)
	fmt.Println(i)
}

$ go run reflect.go
panic: reflect: reflect.flag.mustBeAssignable using unaddressable value
由于 Go 语言的函数调用都是传值的,所以我们得到的反射对象跟最开始的变量没有任何关系,那么直接修改反射对象无法改变原始变量,程序为了防止错误就会崩溃。
需要这样实现:调用 reflect.Value.Elem 获取指针指向的变量
func main() {
	i := 1
	v := reflect.ValueOf(&i)
	v.Elem().SetInt(10)
	fmt.Println(i)
}

2.3、for和range

快速遍历数组、切片、哈希表以及 Channel 等集合类型

循环永动机
#遍历切片时追加的元素不会增加循环的执行次数
func main() {
	arr := []int{1, 2, 3}
	for _, v := range arr {
		arr = append(arr, v)
	}
	fmt.Println(arr)
}

$ go run main.go
1 2 3 1 2 3
原因:编译期将原切片或者数组赋值给一个新变量 ha,在赋值的过程中就发生了拷贝
神奇的指针
func main() {
	arr := []int{1, 2, 3}
	newArr := []*int{}
	for _, v := range arr {
		newArr = append(newArr, &v)
	}
	for _, v := range newArr {
		fmt.Println(*v)
	}
}

$ go run main.go
3 3 3
正确的做法应该是使用 &arr[i] 替代 &v
原因:同时遍历索引和元素的 range 循环时,Go 语言会额外创建一个新的 v2 变量存储切片中的元素,循环中使用的这个变量 v2 会在每一次迭代被重新赋值而覆盖,赋值时也会触发拷贝,因为在循环中获取返回变量的地址都完全相同,所以返回值一样。
随机遍历
func main() {
	hash := map[string]int{
		"1": 1,
		"2": 2,
		"3": 3,
	}
	for k, v := range hash {
		println(k, v)
	}
}
每次运行上述代码可能会得到不同顺序的结果
Go 团队在设计哈希表的遍历时就不想让使用者依赖固定的遍历顺序,所以引入了随机数保证遍历的随机性

2.3、Select

select 是操作系统中的系统调用,我们经常会使用 select、poll 和 epoll 等函数构建 I/O 多路复用模型提升程序的性能。select 能够让 Goroutine 同时等待多个 Channel 可读或者可写,在多个文件或者 Channel状态改变之前,select 会一直阻塞当前线程或者 Goroutine。

func fibonacci(c, quit chan int) {
	x, y := 0, 1
	for {
		select {
		case c <- x:
			x, y = y, x+y
		case <-quit:
			fmt.Println("quit")
			return
		#非阻塞收发使用default,就不会阻塞当前的 Goroutine
		default:
		    println("default")
		}
	}
}
上述控制结构会等待 c <- x 或者 <-quit 两个表达式中任意一个返回。
无论哪一个表达式返回都会立刻执行 case 中的代码,当 select 中的两个 case 同时被触发时,会随机执行其中的一个。
当不存在可以收发的 Channel 时,执行 default 中的语句

实现原理:

select 语句在编译期间会被转换成 OSELECT 节点。每个 OSELECT 节点都会持有一组 OCASE 节点,如果 OCASE 的执行条件是空,那就意味着这是一个 default 节点

常见流程:
在默认的情况下,编译器会使用如下的流程处理 select 语句:
1、将所有的 case 转换成包含 Channel 以及类型等信息的 runtime.scase 结构体;
2、调用运行时函数 runtime.selectgo 从多个准备就绪的 Channel 中选择一个可执行的 runtime.scase 结构体;
3、通过 for 循环生成一组 if 语句,在语句中判断自己是不是被选中的 case;

selv := [3]scase{}
order := [6]uint16
for i, cas := range cases {
    c := scase{}
    c.kind = ...
    c.elem = ...
    c.c = ...
}
chosen, revcOK := selectgo(selv, order, 3)
if chosen == 0 {
    ...
    break
}
if chosen == 1 {
    ...
    break
}
if chosen == 2 {
    ...
    break
}

初始化:
func selectgo(cas0 *scase, order0 *uint16, ncases int) (int, bool) {
	cas1 := (*[1 << 16]scase)(unsafe.Pointer(cas0))
	order1 := (*[1 << 17]uint16)(unsafe.Pointer(order0))
	
	ncases := nsends + nrecvs
	scases := cas1[:ncases:ncases]
	pollorder := order1[:ncases:ncases]
	lockorder := order1[ncases:][:ncases:ncases]

	norder := 0
	for i := range scases {
		cas := &scases[i]
	}

	for i := 1; i < ncases; i++ {
		j := fastrandn(uint32(i + 1))
		pollorder[norder] = pollorder[j]
		pollorder[j] = uint16(i)
		norder++
	}
	pollorder = pollorder[:norder]
	lockorder = lockorder[:norder]

	// 根据 Channel 的地址排序确定加锁顺序
	...
	sellock(scases, lockorder)
	...
}

在编译期间,Go 语言会对 select 语句进行优化,它会根据 select 中 case 的不同选择不同的优化路径:

  • 空的 select 语句会被转换成调用 runtime.block 直接挂起当前 Goroutine;
  • 如果 select 语句中只包含一个 case,编译器会将其转换成 if ch == nil { block }; n; 表达式;首先判断操作的 Channel 是不是空的;然后执行 case 结构中的内容;
  • 如果 select 语句中只包含两个 case 并且其中一个是 default,那么会使用 runtime.selectnbrecv 和 runtime.selectnbsend 非阻塞地执行收发操作;
  • 在默认情况下会通过 runtime.selectgo 获取执行 case 的索引,并通过多个 if 语句执行对应 case 中的代码;

编译器已经对 select 语句进行优化之后,Go 语言会在运行时执行编译期间展开的 runtime.selectgo 函数,该函数会按照以下的流程执行:

  • 随机生成一个遍历的轮询顺序 pollOrder 并根据 Channel 地址生成锁定顺序 lockOrder;
  • 根据 pollOrder 遍历所有的 case 查看是否有可以立刻处理的 Channel;如果存在,直接获取 case 对应的索引并返回;如果不存在,创建 runtime.sudog 结构体,将当前 Goroutine 加入到所有相关 Channel 的收发队列,并调用 runtime.gopark 挂起当前 Goroutine 等待调度器的唤醒;
  • 当调度器唤醒当前 Goroutine 时,会再次按照 lockOrder 遍历所有的 case,从中查找需要被处理的 runtime.sudog 对应的索引;

2.4、defer

defer 会在当前函数返回前执行传入的函数,它会经常被用于关闭文件描述符、关闭数据库连接以及解锁资源。

func main() {
    {
        defer fmt.Println("defer runs")
        fmt.Println("block ends")
    }
    
    fmt.Println("main ends")
}

$ go run main.go
block ends
main ends
defer runs
注:defer 传入的函数不是在退出代码块的作用域时执行的,它只会在当前函数和方法返回之前被调用
func main() {
	startedAt := time.Now()
	defer fmt.Println(time.Since(startedAt))
	
	time.Sleep(time.Second)
}

$ go run main.go
0s
调用 defer 关键字会立刻拷贝函数中引用的外部参数,所以 time.Since(startedAt) 的结果不是在 main 函数退出之前计算的,而是在 defer 关键字调用时计算的,最终导致上述代码输出 0s
------------------------------------------------------------------------------
func main() {
	startedAt := time.Now()
	defer func() { fmt.Println(time.Since(startedAt)) }()
	
	time.Sleep(time.Second)
}

$ go run main.go
1s
虽然调用 defer 关键字时也使用值传递,但是因为拷贝的是函数指针,所以 time.Since(startedAt) 会在 main 函数返回前调用并打印出符合预期的结果

2.5、panic 和 recover

panic 能够改变程序的控制流,调用 panic 后会立刻停止执行当前函数的剩余代码,并在当前 Goroutine 中递归执行调用方的 defer;
recover 可以中止 panic 造成的程序崩溃。它是一个只能在 defer 中发挥作用的函数,在其他作用域中调用不会发挥作用;

  1. panic 只会触发当前 Goroutine 的 defer;
  2. recover 只有在 defer 中调用才会生效;
  3. panic 允许在 defer 中嵌套多次调用;
#跨协程失效
func main() {
	defer println("in main")
	go func() {
		defer println("in goroutine")
		panic("")
	}()

	time.Sleep(1 * time.Second)
}

$ go run main.go
in goroutine
panic:
注:panic 只会触发当前 Goroutine 的延迟函数调用
------------------------------------------------------------------
失效的崩溃恢复:
func main() {
	defer fmt.Println("in main")
	if err := recover(); err != nil {
		fmt.Println(err)
	}

	panic("unknown err")
}

$ go run main.go
in main
panic: unknown err

goroutine 1 [running]:
main.main()
	...
exit status 2
注:recover 是在 panic 之前调用的,并不满足生效的条件,所以我们需要在 defer 中使用 recover 关键字
------------------------------------------------------------
嵌套崩溃:
func main() {
	defer fmt.Println("in main")
	defer func() {
		defer func() {
			panic("panic again and again")
		}()
		panic("panic again")
	}()

	panic("panic once")
}

$ go run main.go
in main
panic: panic once
	panic: panic again
	panic: panic again and again

goroutine 1 [running]:
...
exit status 2
...
注:程序多次调用 panic 也不会影响 defer 函数的正常执行

程序崩溃和恢复的过程:
Go语言笔记_第4张图片

2.6、make 和 new

  • make 的作用是初始化内置的数据结构,也就是我们在前面提到的切片、哈希表和 Channel;
  • new 的作用是根据传入的类型分配一片内存空间并返回指向这片内存空间的指针;
slice := make([]int, 0, 100)
hash := make(map[int]bool, 10)
ch := make(chan int, 5)

i := new(int)
var v int
i := &v

3、并发编程

3.1、Context

Go 语言中用来设置截止日期、同步信号,传递请求相关值的结构体.

主要作用还是在多个 Goroutine 组成的树中同步取消信号以减少对资源的消耗和占用;
虽然它也有传值的功能,但是传递请求的所有参数是一种非常差的设计,这个功能常见的使用场景是传递请求对应用户的认证令牌以及用于进行分布式追踪的请求 ID。

type Context interface {
	//返回 context.Context 被取消的时间,也就是完成工作的截止日期
	Deadline() (deadline time.Time, ok bool)
	//返回一个 Channel,这个 Channel 会在当前工作完成或者上下文被取消后关闭,多次调用 Done 方法会返回同一个 Channel
	Done() <-chan struct{}
	//返回 context.Context 结束的原因,它只会在 Done 方法对应的 Channel 关闭时返回非空的值
	//如果 context.Context 被取消,会返回 Canceled 错误
	//如果 context.Context 超时,会返回 DeadlineExceeded 错误
	Err() error
	//从 context.Context 中获取键对应的值,对于同一个上下文来说,
	//多次调用 Value 并传入相同的 Key 会返回相同的结果,该方法可以用来传递请求特定的数据
	Value(key interface{}) interface{}
}

context.Background、context.TODO、context.WithDeadline 和 context.WithValue 函数会返回实现该接口的私有结构体
func main() {
	ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
	defer cancel()

	go handle(ctx, 500*time.Millisecond)
	select {
	case <-ctx.Done():
		fmt.Println("main", ctx.Err())
	}
}

func handle(ctx context.Context, duration time.Duration) {
	select {
	case <-ctx.Done():
		fmt.Println("handle", ctx.Err())
	case <-time.After(duration):
		fmt.Println("process request with", duration)
	}
}
$ go run context.go
process request with 500ms
main context deadline exceeded

//处理请求时间增加至 1500ms,整个程序都会因为上下文的过期而被中止
$ go run context.go
main context deadline exceeded
handle context deadline exceeded
  • context.Background:上下文的默认值,所有其他的上下文都应该从它衍生出来;
  • context.TODO:应该仅在不确定应该使用哪种上下文时使用;
  • context.WithCancel:从 context.Context 中衍生出一个新的子上下文并返回用于取消该上下文的函数;
  • context.WithDeadline/context.WithTimeout:创建可以被取消的计时器上下文 context.timerCtx;创建 context.timerCtx 的过程中判断了父上下文的截止日期与当前日期,并通过 time.AfterFunc 创建定时器,当时间超过了截止日期后会调用 context.timerCtx.cancel 同步取消信号;
  • context.WithValue:从父上下文中创建一个子上下文,传值的子上下文使用 context.valueCtx 类型;

3.2、同步原语与锁

锁是一种并发编程中的同步原语(Synchronization Primitives),它能保证多个 Goroutine 在访问同一片内存时不会出现竞争条件(Race condition)等问题。
Go语言笔记_第5张图片

3.2.1、Mutex
type Mutex struct {
	state int32  //当前互斥锁的状态
	sema  uint32  //用于控制锁状态的信号量
}
状态:
-mutexLocked — 表示互斥锁的锁定状态
-mutexWoken — 表示从正常模式被从唤醒
-mutexStarving — 当前的互斥锁进入饥饿状态
-waitersCount — 当前互斥锁上等待的 Goroutine 个数

正常模式:锁的等待者会按照先进先出的顺序获取锁。
饥饿模式:互斥锁会直接交给等待队列最前面的 Goroutine.
正常模式下的互斥锁能够提供更好地性能,饥饿模式的能避免 Goroutine 由于陷入等待无法获取锁而造成的高尾延时。

Go语言笔记_第6张图片

3.2.2、RWMutex

读写互斥锁 sync.RWMutex 是细粒度的互斥锁,它不限制资源的并发读,但是读写、写写操作无法并行执行。

  • 写操作使用 sync.RWMutex.Lock 和 sync.RWMutex.Unlock 方法
  • 读操作使用 sync.RWMutex.RLock 和 sync.RWMutex.RUnlock 方法
    Go语言笔记_第7张图片
3.2.3、WaitGroup
//sync.WaitGroup 可以等待一组 Goroutine 的返回,将原本顺序执行的代码在多个 Goroutine 中并发执行,加快程序处理的速度
requests := []*Request{...}
wg := &sync.WaitGroup{}
wg.Add(len(requests))

for _, request := range requests {
    go func(r *Request) {
        defer wg.Done()
        // res, err := service.call(r)
    }(request)
}
wg.Wait()
  • sync.WaitGroup 必须在 sync.WaitGroup.Wait 方法返回之后才能被重新使用;
  • sync.WaitGroup.Done 只是对 sync.WaitGroup.Add 方法的简单封装,我们可以向 sync.WaitGroup.Add 方法传入任意负数(需要保证计数器非负)快速将计数器归零以唤醒等待的 Goroutine;
  • 可以同时有多个 Goroutine 等待当前 sync.WaitGroup 计数器的归零,这些 Goroutine 会被同时唤醒;
3.2.4、Once

保证在 Go 程序运行期间的某段代码只会执行一次

  • sync.Once.Do 方法中传入的函数只会被执行一次,哪怕函数中发生了 panic;
  • 两次调用 sync.Once.Do 方法传入不同的函数只会执行第一次调传入的函数;
3.2.5、Cond

包含条件变量 sync.Cond,它可以让一组的 Goroutine 都在满足特定条件时被唤醒,sync.Cond 结构体在初始化时都需要传入一个互斥锁

sync.Cond.Wait :会将当前 Goroutine 陷入休眠状态,它的执行过程分成以下两个步骤:

  • 调用 runtime.notifyListAdd 将等待计数器加一并解锁;
  • 调用 runtime.notifyListWait 等待其他 Goroutine 的唤醒并加锁:
    sync.Cond.Signal 和 sync.Cond.Broadcast 就是用来唤醒陷入休眠的 Goroutine 的方法
  • sync.Cond.Signal 方法会唤醒队列最前面的 Goroutine
  • sync.Cond.Broadcast 方法会唤醒队列中全部的 Goroutine
  • sync.Cond.Wait 在调用之前一定要使用获取互斥锁,否则会触发程序崩溃;
  • sync.Cond.Signal 唤醒的 Goroutine 都是队列最前面、等待最久的 Goroutine;
  • sync.Cond.Broadcast 会按照一定顺序广播通知等待的全部 Goroutine;
3.2.6、ErrGroup

在一组 Goroutine 中提供了同步、错误传播以及上下文取消的功能,我们可以使用如下所示的方式并行获取网页的数据.

var g errgroup.Group
var urls = []string{
    "http://www.golang.org/",
    "http://www.google.com/",
    "http://www.somestupidname.com/",
}
for i := range urls {
    url := urls[i]
    g.Go(func() error {
        resp, err := http.Get(url)
        if err == nil {
            resp.Body.Close()
        }
        return err
    })
}
if err := g.Wait(); err == nil {
    fmt.Println("Successfully fetched all URLs.")
}
3.2.7、Semaphore

信号量是在并发编程中常见的一种同步机制,在需要控制访问资源的进程数量时就会用到信号量,它会保证持有的计数器在 0 到初始化的权重之间波动。

  • 每次获取资源时都会将信号量中的计数器减去对应的数值,在释放时重新加回来;
  • 当遇到计数器大于信号量大小时,会进入休眠等待其他线程释放信号;
    Go 语言的扩展包中就提供了带权重的信号量 golang/sync/semaphore.Weighted,我们可以按照不同的权重对资源的访问进行管理,这个结构体对外也只暴露了四个方法:
  • golang/sync/semaphore.NewWeighted 用于创建新的信号量;
  • golang/sync/semaphore.Weighted.Acquire 阻塞地获取指定权重的资源,如果当前没有空闲资源,会陷入休眠等待;
  • golang/sync/semaphore.Weighted.TryAcquire 非阻塞地获取指定权重的资源,如果当前没有空闲资源,会直接返回 false;
  • golang/sync/semaphore.Weighted.Release 用于释放指定权重的资源;
3.2.8、SingleFlight

在一个服务中抑制对下游的多次重复请求。一个比较常见的使用场景是:我们在使用 Redis 对数据库中的数据进行缓存,发生缓存击穿时,大量的流量都会打到数据库上进而影响服务的尾延时.

在资源的获取非常昂贵时(例如:访问缓存、数据库),就很适合使用 golang/sync/singleflight.Group 优化服务
type service struct {
    requestGroup singleflight.Group
}

func (s *service) handleRequest(ctx context.Context, request Request) (Response, error) {
    v, err, _ := requestGroup.Do(request.Hash(), func() (interface{}, error) {
        rows, err := // select * from tables
        if err != nil {
            return nil, err
        }
        return rows, nil
    })
    if err != nil {
        return nil, err
    }
    return Response{
        rows: rows,
    }, nil
}

3.3、计时器

var timers struct {
	lock         mutex
	gp           *g
	created      bool
	sleeping     bool
	rescheduling bool
	sleepUntil   int64
	waitnote     note
	t            []*timer
}

全局四叉堆
分片四叉堆

3.4、Channel

支撑 Go 语言高性能并发编程模型的重要结构,Go 核心的数据结构和 Goroutine 之间的通信方式

  • 先进先出:先从 Channel 读取数据的 Goroutine 会先接收到数据;先向 Channel 发送数据的 Goroutine 会得到先发送数据的权利;
  • 无锁管道:同步 Channel — 不需要缓冲区,发送方会直接将数据交给(Handoff)接收方;异步 Channel — 基于环形缓存的传统生产者消费者模型;chan struct{} 类型的异步 Channel — struct{} 类型不占用内存空间,不需要实现缓冲区和直接发送(Handoff)的语义;
type hchan struct {
	qcount   uint
	dataqsiz uint
	buf      unsafe.Pointer
	elemsize uint16
	closed   uint32
	elemtype *_type
	sendx    uint
	recvx    uint
	recvq    waitq
	sendq    waitq

	lock mutex
}
qcount — Channel 中的元素个数;
dataqsiz — Channel 中的循环队列的长度;
buf — Channel 的缓冲区数据指针;
sendx — Channel 的发送操作处理到的位置;
recvx — Channel 的接收操作处理到的位置;

创建管道:
如果我们不向 make 传递表示缓冲区大小的参数,那么就会设置一个默认值 0,也就是当前的 Channel 不存在缓冲区

发送数据:

  • 直接发送:如果目标 Channel 没有被关闭并且已经有处于读等待的 Goroutine,那么 runtime.chansend 会从接收队列 recvq 中取出最先陷入等待的 Goroutine 并直接向它发送数据;
    1、调用 runtime.sendDirect 将发送的数据直接拷贝到 x = <-c 表达式中变量 x 所在的内存地址上;
    2、调用 runtime.goready 将等待接收数据的 Goroutine 标记成可运行状态 Grunnable 并把该 Goroutine 放到发送方所在的处理器的 runnext 上等待执行,该处理器在下一次调度时会立刻唤醒数据的接收方;
  • 阻塞发送:当 Channel 没有接收者能够处理数据时,向 Channel 发送数据会被下游阻塞
    接收数据:
  • 直接接收:当 Channel 的 sendq 队列中包含处于等待状态的 Goroutine 时,该函数会取出队列头等待的 Goroutine,处理的逻辑和发送时相差无几,只是发送数据时调用的是 runtime.send 函数,而接收数据时使用 runtime.recv
  • 阻塞接收:当 Channel 的发送队列中不存在等待的 Goroutine 并且缓冲区中也不存在任何数据时,从管道中接收数据的操作会变成阻塞的,
    然而不是所有的接收操作都是阻塞的,与 select 语句结合使用时就可能会使用到非阻塞的接收操作
  • 关闭管道:
    编译器会将用于关闭管道的 close 关键字转换成 OCLOSE 节点以及 runtime.closechan 函数

3.5、调度器

4、内存管理

4.1、内存分配器

设计原理:内存管理一般包含三个不同的组件,分别是用户程序(Mutator)、分配器(Allocator)和收集器(Collector),当用户程序申请内存时,它会通过内存分配器申请新内存,而分配器会负责从堆中初始化相应的内存区域。

Go语言笔记_第8张图片

4.1.1、分配方法

1、线性分配器

只需要在内存中维护一个指向内存特定位置的指针,如果用户程序向分配器申请内存,分配器只需要检查剩余的空闲内存、返回分配的内存区域并修改指针在内存中的位置;
虽然线性分配器实现为它带来了较快的执行速度以及较低的实现复杂度,但是线性分配器无法在内存被释放时重用内存;
因为线性分配器具有上述特性,所以需要与合适的垃圾回收算法配合使用;标记压缩(Mark-Compact)、复制回收(Copying GC)和分代回收(Generational GC)等算法,它们可以通过拷贝的方式整理存活对象的碎片,将空闲内存定期合并,这样就能利用线性分配器的效率提升内存分配器的性能了

Go语言笔记_第9张图片
2、空闲链表分配器

它在内部会维护一个类似链表的数据结构。当用户程序申请内存时,空闲链表分配器会依次遍历空闲的内存块,找到足够大的内存,然后申请新的资源并修改链表
首次适应(First-Fit)— 从链表头开始遍历,选择第一个大小大于申请内存的内存块;
循环首次适应(Next-Fit)— 从上次遍历的结束位置开始遍历,选择第一个大小大于申请内存的内存块;
最优适应(Best-Fit)— 从链表头遍历整个链表,选择最合适的内存块;
隔离适应(Segregated-Fit)— 将内存分割成多个链表,每个链表中的内存块大小相同,申请内存时先找到满足条件的链表,再从链表中选择合适的内存块;

Go语言笔记_第10张图片

4.1.2、分级分配

TCMalloc(线程缓存分配):使用多级缓存将对象根据大小分类,并按照类别实施不同的分配策略

Go 语言的内存分配器会根据申请分配的内存大小选择不同的处理逻辑,运行时根据对象的大小将对象分成微对象、小对象和大对象三种
内存分配器不仅会区别对待大小不同的对象,还会将内存分成不同的级别分别管理,运行时分配器都会引入线程缓存(Thread Cache)、中心缓存(Central Cache)和页堆(Page Heap)三个组件分级管理内存

Go语言笔记_第11张图片
Go语言笔记_第12张图片

4.1.3、虚拟内存布局

Go 语言 1.10 以前的版本堆区的内存空间都是连续的,在1.11 版本使用稀疏的堆内存空间替代了连续的内存,解决了连续内存带来的限制。
Go语言笔记_第13张图片
Go语言笔记_第14张图片

线性内存:

  • spans 区域存储了指向内存管理单元 runtime.mspan 的指针,每个内存单元会管理几页的内存空间,每页大小为 8KB;
  • bitmap 用于标识 arena 区域中的那些地址保存了对象,位图中的每个字节都会表示堆区中的 32 字节是否空闲;
  • arena 区域是真正的堆区,运行时会将 8KB 看做一页,这些内存页中存储了所有在堆上初始化的对象;
    线性的堆内存需要预留大块的内存空间,但是申请大块的内存空间而不使用是不切实际的,不预留内存空间却会在特殊场景下造成程序崩溃

稀疏内存:
稀疏的内存布局不仅能移除堆大小的上限,还能解决 C 和 Go 混合使用时的地址空间冲突问题,但内存管理更加复杂。

4.1.4、地址空间

内存最终都是要从操作系统中申请的,所以 Go 语言的运行时构建了操作系统的内存管理抽象层:
Go语言笔记_第15张图片

4.1.5、内存管理组件

Go语言笔记_第16张图片
概述:

  • 每一个处理器都会分配一个线程缓存 runtime.mcache 用于处理微对象和小对象的分配,它们会持有内存管理单元 runtime.mspan;
  • 当内存管理单元中不存在空闲对象时,它们会从 runtime.mheap 持有的 134 个中心缓存 runtime.mcentral 中获取新的内存单元,中心缓存属于全局的堆结构体 runtime.mheap,它会从操作系统中申请内存;
  • runtime.mheap 持有 4,194,304 runtime.heapArena,每个 runtime.heapArena 都会管理 64MB 的内存,单个 Go 语言程序的内存上限也就是 256TB

组件:

  • runtime.mspan 内存管理单元:
  • runtime.mcache 线程缓存:
  • runtime.mcentral 中心缓存:
  • runtime.mheap 页堆:
4.1.6、内存分配

堆上所有的对象都会通过调用 runtime.newobject 函数分配内存,该函数会调用 runtime.mallocgc 分配指定大小的内存空间,根据对象的大小执行不同的分配逻辑

  • 微对象 (0, 16B) — 先使用微型分配器,再依次尝试线程缓存、中心缓存和堆分配内存
  • 小对象 [16B, 32KB] — 依次尝试使用线程缓存、中心缓存和堆分配内存
  • 大对象 (32KB, +∞) — 直接在堆上分配内存
    Go语言笔记_第17张图片

4.2、垃圾收集器

用户程序(Mutator)会通过内存分配器(Allocator)在堆上申请内存,而垃圾收集器(Collector)负责回收堆上的内存空间,内存分配器和垃圾收集器共同管理着程序中的堆内存空间。

最常见标记清除(Mark-Sweep)垃圾收集算法是跟踪式垃圾收集器

  • 标记阶段 — 从根对象出发查找并标记堆中所有存活的对象;
  • 清除阶段 — 遍历堆中的全部对象,回收未被标记的垃圾对象并将回收的内存加入空闲链表;

标记阶段结束后会进入清除阶段,在该阶段中收集器会依次遍历堆中的所有对象,释放其中没有被标记的 B、E 和 F 三个对象并将新的空闲内存空间以链表的结构串联起来,方便内存分配器的使用
整个过程需要标记对象的存活状态,用户程序在垃圾收集的过程中也不能执行

Go语言笔记_第18张图片

三色抽象将程序中的对象分成白色、黑色和灰色三类以缩短 STW 的时间

  • 白色对象 — 潜在的垃圾,其内存可能会被垃圾收集器回收;
  • 黑色对象 — 活跃的对象,包括不存在任何引用外部指针的对象以及从根对象可达的对象;
  • 灰色对象 — 活跃的对象,因为存在指向白色对象的外部指针,垃圾收集器会扫描这些对象的子对象;
    步骤:
  • 从灰色对象的集合中选择一个灰色对象并将其标记成黑色;
  • 将黑色对象指向的所有对象都标记成灰色,保证该对象和被该对象引用的对象都不会被回收;
  • 重复上述两个步骤直到对象图中不存在灰色对象;

当三色的标记清除的标记阶段结束之后,应用程序的堆中就不存在任何的灰色对象,我们只能看到黑色的存活对象以及白色的垃圾对象,垃圾收集器可以回收这些白色的垃圾

Go语言笔记_第19张图片

屏障技术:像是一个钩子方法,它是在用户程序读取、创建对象以及更新对象指针时执行的一段代码,根据操作类型的不同,我们可以将它们分成读屏障(Read barrier)和写屏障(Write barrier)两种

  • 强三色不变性 — 黑色对象不会指向白色对象,只会指向灰色对象或者黑色对象;
  • 弱三色不变性 — 黑色对象指向的白色对象必须包含一条从灰色对象经由多个白色对象的可达路径;
    垃圾收集器的演进过程:

Go语言笔记_第20张图片

4.3、栈空间管理

栈区的内存一般由编译器自动分配和释放,存储函数的入参以及局部变量,会随着函数的创建而创建,函数的返回而消亡。

  • 寄存器:栈寄存器是 CPU 寄存器中的一种,它的主要作用是跟踪函数的调用栈
    Go 语言的汇编代码包含 BP 和 SP 两个栈寄存器,分别存储了栈的基址指针和栈顶的地址
  • 线程栈:多数架构上默认栈大小都在 2 ~ 4 MB 左右,极少数架构会使用 32 MB 的栈,用户程序可以在分配的栈上存储函数参数和局部变量
  • 逃逸分析:编译器使用逃逸分析决定哪些变量应该在栈上分配,哪些变量应该在堆上分配
    指向栈对象的指针不能存在于堆中;指向栈对象的指针不能在栈对象回收后存活;
  • 栈内存空间:Go 语言使用用户态线程 Goroutine 作为执行上下文,它的额外开销和默认栈大小都比线程小很多

4.4、内存管理与Java差异

4.4.1、内存结构差异

操作系统为每个进程分配一个连续的虚拟内存地址空间,并将该进程内存空间划分成多个不同用途的逻辑区域:
Go语言笔记_第21张图片

Java程序的运行时内存被划分为元数据区(方法区)、堆、虚拟机栈、本地方法栈、程序计数器;
JVM的堆内存,虚拟机栈、本地方法栈、程序计数器则对应了上图中JVM的栈区,元数据区则是JVM另外开辟的内存块;
元数据区、堆内存为所有线程共享,多线程访问时需要进行同步控制。虚拟机栈、本地方法栈、程序计数器都是线程私有的内存空间,访问时无需加锁,速度很快;

  • 元数据区是JVM向操作系统申请的堆外内存,用于实现“方法区”,主要存储虚拟机加载class的类信息、JIT编译的代码、运行时常量池等数据,其默认大小由系统的可用物理内存上限限制;
  • 堆内存是被所有线程共享的一块内存区域,在虚拟机启动时创建。它存放了包括几乎所有的Java对象以及数组,这也是垃圾收集器关注的主要内存区;
  • 虚拟机栈即我们常说的栈空间,其生命周期与线程相同。线程的栈空间存储了方法调用的栈帧,每个栈帧则存储局部变量表、操作数栈、动态链接、方法出口等信息;
  • 本地方法栈为JVM使用到的Native方法提供内存空间,而虚拟机栈为JVM执行Java方法提供内存空间。HotSpot将虚拟机栈与本地方法栈合并管理;
  • 程序计数器是一块较小的内存空间,用来指向当前线程需要执行的下一条字节码指令;
  • 新生代将内存划分为eden空间和两块较小的survivor空间,每次使用eden和其中一块survivor。
    当回收时,将eden和survivor中还存活着的对象一次性地复制到另外一块survivor空间上,最后清理掉eden和刚才用过的survivor空间,这为新生代“标记-复制”回收内存提供算法实现便捷性。eden区与survivor区的大小比例是8:1:1;
  • 老年代一般存放存活周期较长的对象以及大对象。老年代采用“标记-清除”或“标记-整理”算法回收内存;
  • 新建对象总是优先在新生代的eden区中分配,“熬过”多次GC的对象可以从新生代晋升到老年代;

Go语言笔记_第22张图片
Go语言笔记_第23张图片

Go的内存分配器是分层级的,由mcache/mcentral/mheap 三个组件构成,mcentral和mheap为所有工作线程所共享,在内存分配时存在同步竞争的情况。

  • 缓存组件mcache与工作线程(goroutine)绑定,是goroutine私有的内存空间。在mcache中为对象分配内存时,无需竞争,性能很高;
  • 中间组件mcentral 只负责一种规格的内存块,为mcache缓存组件提供备用的特定规格的可用空间;
    mcache的内存扩容请求会被分散到不同的mcentral 组件上,以减小共享内存的竞争锁粒度;
  • 堆组件mheap负责管理用户程序的所有可用堆内存空间以及为大对象直接分配内存。它为上层组件提供扩容支持。当空间不足时,mheap组件向操作系统申请内存;

Go语言笔记_第24张图片

4.4.2、对象内存分配差异

Go以及Java都支持变量的逃逸分析,逃逸到栈上的对象会随着方法退出而自然回收,而分配到堆内存的对象则需要垃圾收集器回收才能释放出内存空间。

JVM会为新创建的线程在stack栈区分配一块私有的线程栈空间。某一个线程中创建的Java对象可能被分配在新生代,也可能分配在老年代:

  • 首先检查该类是否已加载、解析、初始化。如果没有,则执行类加载的过程;
  • 分配对象内存时,检查是否启用-XX: +UseTLAB参数。如果启用TLAB,则直接从当前线程的TLAB空间以lock free方式分配指定大小的内存块,速度很快;
  • 如果未启用UseTLAB或者TLAB分配失败,JVM将继续在eden区或老年代上为对象分配空间,这时需要做同步操作;
  • 对象内存分配成功后,JVM初始化对象零值、设置对象头等元信息,执行对象初始化方法,最后将对象引用压入线程的栈内存中;

Java大对象的分配过程稍有不同,JVM总是直接在老年代中为其分配存储空间。我们可以通过-XX:PretenureSizeThreshold参数来设定大对象的阈值,该参数默认值为0,说明对象总是先在eden区分配,不管这个对象有多大

Go内存分配器管理span以及object两种类型的内存块,span是Go内存管理的基本单元,由多个地址连续的页(8k大小的page内存块)组成的⼤块内存。object则是将span按照特定规格(size class)切分成的多个⼩块,每个⼩块都可以用来存储⼀个对象;
Go对象的内存分配就是从有限的67种规格中找出与对象大小最合适的一块可用内存块的过程。Go使用“空闲列表”方式管理可分配的内存空间,相同规格的内存块连接成一个双向链表。

  • mcache缓存是goroutine的私有内存空间,直接为当前goroutine无锁分配小对象的内存块,速度很快。内存分配器首先根据对象大小获取mcache中对应size class的span链表,并从表头span中提取object块进行分配;
  • 如果分配器发现mcache下没有对应规格的可用span资源,则会尝试从堆区相应class的mcentral区域中申请扩容。分配器将申请到的span资源链接到mcache链表,继续为对象分配object块空间;
  • 如果mcentral中没有找到可用的内存块,分配器会向mheap申请扩容,扩容成功后继续为对象分配内存;

对于大对象,Go的内存分配器直接在mheap分配内存,如果没有找到合适的span内存块,分配器将向操作系统申请扩容后继续分配。
提取的span内存块如果超过了对象规格所需的页数,分配器将尝试分割该span合适大小分配给对象,并合并剩余的空间归还给mheap管理,以减少堆内存碎片。

Go语言笔记_第25张图片

4.4.3、垃圾收集差异

Go与Java的GC策略是判断对象是否存活,并对其进行标记,或采用“复制”算法、“清除”算法、“整理”算法等完成不可引用对象的内存回收操作。

JVM的垃圾收集是分代的收集
JVM的GC策略根据内存分代可以分为Minor GC(新生代垃圾收集)和Full GC(Major GC,老年代垃圾收集)两类

Go语言笔记_第26张图片
Go语言笔记_第27张图片

  • Go垃圾收集器基于优化改进的“标记-清除”算法,特征为“非分代、非紧缩、写屏障、三色标记、并发标记清理”,“非紧缩”的特征使得回收的内存块非常容易的复用,较少产生内存碎片,基本上不需要压缩整理,与JVM中的CMS垃圾收集器原理上是非常相似的。
  • 垃圾收集器在回收对象内存的过程中,总是需要挂起所有的用户线程(即STW,stop the world),以避免GC线程在回收时对象的引用关系还在不断变化导致回收结果不准确;STW可能会因GC时间过长而使得用户线程长时间的停顿。
  • Go GC的目标就是尽量减小STW的时间,以使得程序能够获取最大限度的响应速度

Go语言笔记_第28张图片
总结:

  1. Java的分代内存管理支持各个内存代选择合适的GC算法实现,通常GC只需要对某一个内存代进行回收;
  2. Go对整块堆内存分层级管理,GC时就不得不扫描整块区域;
  3. Java与Go为了实现对象空间的快速分配,都为线程分配了一块私有堆内存;
  4. Java分配确定的大小对象内存,回收时更容易造成堆内存碎片问题;
  5. Go按size class的分块对象内存模式虽然在重用性上得到了改善,但是又造成了一些浪费。

5、Go协程

5.1、简介

Go语言最大的特色就是从语言层面支持并发(Goroutine),Goroutine是Go中最基本的执行单元。事实上每一个Go程序至少有一个Goroutine:主Goroutine。当程序启动时,它会自动创建

线程(Thread):有时被称为轻量级进程(Lightweight Process,LWP),是程序执行流的最小单元。一个标准的线程由线程ID,当前指令指针(PC),寄存器集合和堆(heap, 一般由程序员分配释放)栈(stack,由编译器自动分配释放 ,存放函数的参数值,局部变量的值等)组成。另外,线程是进程中的一个实体,是被系统独立调度和分派的基本单位,线程自己不拥有系统资源,只拥有一点儿在运行中必不可少的资源,但它可与同属一个进程的其它线程共享进程所拥有的全部资源。

  • 线程拥有自己独立的栈和共享的堆,共享堆,不共享栈,线程的切换一般也由操作系统调度

协程(coroutine):又称微线程与子例程(或者称为函数)一样,协程(coroutine)也是一种程序组件。相对子例程而言,协程更为一般和灵活,但在实践中使用没有子例程那样广泛;

  • 和线程类似,共享堆,不共享栈,协程的切换一般由程序员在代码中显式控制。它避免了上下文切换的额外耗费,兼顾了多线程的优点,简化了高并发程序的复杂
package main

import (
    "sync"
)

var wg sync.WaitGroup

func say(s string) {
    for i := 0; i < 5; i++ {
        println(s)
    }
    wg.Done()
}

func main() {
    wg.Add(2)
    
    go say("Hello")
    go say("World")
    
    wg.Wait()
}
顺序打印5个hello,5个world
package main

import (
    "sync"
)

var wg sync.WaitGroup

func say(s string, c chan string) {
    for i := 0; i < 5; i++ {
        c <- s
    }
    wg.Done()
}

func main() {
    wg.Add(2)

    ch := make(chan string) // 实例化一个管道

    go say("Hello", ch)
    go say("World", ch)

    for {
        println(<-ch) //循环从管道取数据
    }

    wg.Wait()
}
go启动的协程同时向这个2个管道输出数据,主线程使用了一个for循环从管道里面取数据,其实就是一个生产者和消费者模式
World 和 Hello 进入管道的顺序是不固定的,如果循环数据放大,或者在里面加个睡眠会发生死锁

标准的做法是主动关闭管道,或者你知道你应该什么时候关闭管道, 当然你结束程序管道自然也会关掉
i := 1
for {
    str := <- ch
    println(str)

    if i >= 10{
        close(ch)
        break
    }
    i++
}
package main

import (
    "strconv"
    "fmt"
    "time"
)

func main() {
    ch1 := make(chan int)
    ch2 := make(chan string)
    go pump1(ch1)
    go pump2(ch2)
    go suck(ch1, ch2)
    time.Sleep(time.Duration(time.Second*30))
}

func pump1(ch chan int) {
    for i := 0; ; i++ {
        ch <- i * 2
        time.Sleep(time.Duration(time.Second))
    }
}

func pump2(ch chan string) {
    for i := 0; ; i++ {
        ch <- strconv.Itoa(i+5)
        time.Sleep(time.Duration(time.Second))
    }
}

func suck(ch1 chan int, ch2 chan string) {
    chRate := time.Tick(time.Duration(time.Second*5)) // 定时器
    for {
        select {
        case v := <-ch1:
            fmt.Printf("Received on channel 1: %d\n", v)
        case v := <-ch2:
            fmt.Printf("Received on channel 2: %s\n", v)
        case <-chRate:
            fmt.Printf("Log log...\n")
        }
    }
}
pump1 和 pump2是2个不同的管道,通过select可以实现在不同管道之间切换,
哪个管道有数据就从哪个管道里面取数据,如果都没数据就等着,还有一个定时器功能可以每隔一段时间向管道输出内容

5.2、goroutine并发原理和调度机制

Go语言笔记_第29张图片

参考

不要以共享内存的方式来通信,相反,要通过通信来共享内存
DO NOT COMMUNICATE BY SHARING MEMORY; INSTEAD, SHARE MEMORY BY COMMUNICATING.

普通的线程并发模型,就是像Java、C++、或者Python,他们线程间通信都是通过共享内存的方式来进行的。非常典型的方式就是,在访问共享数据(例如数组、Map、或者某个结构体或对象)的时候,通过锁来访问,因此,在很多时候,衍生出一种方便操作的数据结构,叫做“线程安全的数据结构”。例如Java提供的包”java.util.concurrent”中的数据结构。

无论语言层面何种并发模型,到了操作系统层面,一定是以线程的形态存在的。而操作系统根据资源访问权限的不同,体系架构可分为用户空间和内核空间;内核空间主要操作访问CPU资源、I/O资源、内存资源等硬件资源,为上层应用程序提供最基本的基础资源,用户空间呢就是上层应用程序的固定活动空间,用户空间不可以直接访问资源,必须通过“系统调用”、“库函数”或“Shell脚本”来调用内核空间提供的资源。

用户级线程模型: 多个用户态的线程对应着一个内核线程,程序线程的创建、终止、切换或者同步等线程工作必须自身来完成。它可以做快速的上下文切换。缺点是不能有效利用多核CPU
Go语言笔记_第30张图片

内核级线程模型: 直接调用操作系统的内核线程,所有线程的创建、终止、切换、同步等操作,都由内核来完成。一个用户态的线程对应一个系统线程,它可以利用多核机制,但上下文切换需要消耗额外的资源。C++就是这种
Go语言笔记_第31张图片

两级线程模型: 介于用户级线程模型和内核级线程模型之间的一种线程模型
一个进程中可以对应多个内核级线程,但是进程中的线程不和内核线程一一对应,这种线程模型会先创建多个内核级线程,然后用自身的用户级线程去对应创建的多个内核级线程,自身的用户级线程需要本身程序去调度,内核级的线程交给操作系统内核去调度

  • M个用户线程对应N个系统线程,缺点增加了调度器的实现难度
    Go语言笔记_第32张图片

Go语言笔记_第33张图片
Go语言笔记_第34张图片

MPG模型

四个结构体

  • M指的是Machine,操作系统用户态线程,一个M直接关联了一个内核线程。一个M被创建后会在P空闲队列中获取P进行绑定,未绑定则进入阻塞状态;
  • P指的是”processor”,代表了M所需的上下文环境,也是处理用户级代码逻辑的处理器。它负责衔接M和G的调度上下文,将等待执行的G与M对接;P的数量由环境变量中的GOMAXPROCS决定,通常来说它是和核心数对应;
  • G指的是Goroutine,其实本质上也是一种轻量级的用户级线程。包括了调用栈,重要的调度信息,例如channel等;
  • S:Scheduler,全局调度器,维护M、P和G队列,负责调度。

两个队列

  • 本地队列:每个P维护一个本地队列,存放G,当前与P绑定的M若新生成G,会放入本地队列,当本地队列满时会拿出一半的本地队列中的G放入全局队列;
  • 全局队列:存放本地队列溢出的G。调度过程中有1/61的概率检查全局队列,确保全局队列中的G也会被调度

抢占式调度逻辑

调度本质:调度器P将协程G合理地分配到系统线程M上执行

  • M绑定的P首先有1/61概率从全局队列获取G,60/61概率从本地队列获取G
  • 全局队列情况下如果没有获取到G,那么从本地队列获取G
  • 如果本地队列没有G,那么P从其他P的本地队列窃取G
  • 如果窃取不到G,那么从全局队列中获取一部分G到本地队列,获取n = min(len(GQ)/GOMAXPROCS + 1, len(GQ/2))个
  • P获取到G后,绑定的M负责执行G,M必须是运行状态的线程,否则不会真正执行

如果当前的M执行的G调用syscall阻塞,P会与M进行分离,M负责执行阻塞的G,P带着队列中的G绑定到新的M中,继续执行其他G;使得虽然当前G进入阻塞,但并没有影响到P去执行其他G
M执行的G阻塞操作返回后,由于没有了P,失去切换上下文执行后续逻辑的机会,因此尝试获取新的P去执行,如果获取不到P,M就把当前G放入全局队列等待调度,自己置于休眠状态Go语言笔记_第35张图片

窃取

如果当前M绑定的P中没有可执行的G,那么就会随机从其他P中拿取一般的G放入自己的队列,以提高资源利用率
Go语言笔记_第36张图片

线程自旋

线程自旋相对于线程阻塞,表现为循环执行指定的逻辑,而不进入阻塞状态。在go的调度逻辑中,为了实现高性能的并发,如果全局队列和本地队列都为空,绑定P的M没有G可以执行,会进入自旋状态等待新的G,不会进入阻塞状态休眠,减少了M的上下文切换成本;
只有绑定了P的M会进入自旋状态,因此最多会有GOMAXPROCS个自旋线程,避免了浪费过多系统资源,其余未绑定的空闲M依然会进入休眠状态

优点:
1、开销小
POSIX的thread API虽然能够提供丰富的API,例如配置自己的CPU亲和性,申请资源等等,线程在得到了很多与进程相同的控制权的同时,开销也非常的大,在Goroutine中则不需这些额外的开销,所以一个Golang的程序中可以支持10w级别的Goroutine。
每个 goroutine (协程) 默认占用内存远比 Java 、C 的线程少(goroutine:2KB ,线程:8MB)

2、调度性能好
在Golang的程序中,操作系统级别的线程调度,通常不会做出合适的调度决策。例如在GC时,内存必须要达到一个一致的状态。在Goroutine机制里,Golang可以控制Goroutine的调度,从而在一个合适的时间进行GC。
在应用层模拟的线程,它避免了上下文切换的额外耗费,兼顾了多线程的优点。简化了高并发程序的复杂度。

你可能感兴趣的:(go,golang)