Go专家编程-控制结构、协程、内存管理

2. 控制结构

2.1 select

select 是 Go 在语言层面提供的多路 I/O 复用机制,用于检测多个管道是否就绪(即可读或可写),其特性跟管道息息相关。

用法特性

管道读写

select 只能作用于管道,包括数据读取和写入。每个 case 语句只能操作一个管道。

func selectForChan(c chan string) {
	var recv string
	send := "Hello"
	select {
	case recv = <-c:
		fmt.Printf("received %s\n", recv)
	case c <- send:
		fmt.Printf("sent %s\n", send)
	}
}
channel 空间channel 占用情况select 执行结果
没有缓冲区不能读也不能写阻塞
有缓冲区可以写入数据写操作对应 case 语句
有缓冲区缓冲区放满数据读操作对应 case 语句
有缓冲区有部分数据且可写入数据随机挑选 case 语句执行
返回值

select 下 case 读取管道时可以给局部变量赋值,最多两个

func selectAssign(c chan string) {
	select {
	case <-c:
		fmt.Printf("0")
	case d := <-c:
		fmt.Printf("1: received %s\n", d)
	case d, ok := <-c:
		if !ok {
			fmt.Printf("no data found\n")
			break
		}
		fmt.Printf("2: received %s\n", d)
	}
}
default

default 语句不能处理管道读写操作,当所有 case 语句都阻塞时执行 default 语句

使用举例

永久阻塞
func main() {
    select {}
}

select 语句不包含 case 语句和 default 语句,协程(main)将陷入永久性阻塞,main 函数不会退出

快速检错

使用 select 语句快速检查管道中是否有错误并且避免陷入循环

// k8s 调度器
select {
case manageJobErr = <-errCh:
	if manageJobErr != nil {
		break
	}
default:
}

为什么通过 select 来检查错误?而不是采用 switch

当对象为管道的时候,确实只能用 select 来操作管道,switch 只能在同一协程内执行

限时等待
ch1 := make(chan string)
// 激活一个goroutine,但5秒之后才发送数据
go func() {
	time.Sleep(5 * time.Second)
	ch1 <- "put value into ch1"
}()
select {
case val := <-ch1:
	fmt.Println("recv value from ch1:", val)
	return
// 只等待3秒,然后就结束
case <-time.After(3 * time.Second):
	fmt.Println("3 second over, timeover")
}

实现原理

  • 为什么每个 case 语句只能处理一个管道?-- 每个 scase 中仅能存放一个管道

  • 为什么 case 语句的执行顺序是随机的(多个 case 都就绪的情况下)?

  • 为什么在 case 语句中向值为 nil 的管道中写数据不会触发 panic?

数据结构
type scase struct {
	c    *hchan         // 操作的管道
	kind uint16         // case 类型
	elem unsage.Pointer // data elemen
	...
}

const (
	caseNil = iota // 管道值为 nil
	caseRecv       // 读管道 case
	caseSend       // 写管道 case
	caseDefault    // default
)

caseNil 表示操作的管道值为 nil,既不可读也不可写,永远不会被命中,在运行时会被忽略而非 panic

default 为特殊类型的 case 语句,不会操作管道,select 中仅可存在一个,可以出现在任意位置

elem 表示数据存放的地址,caseRecv 时表示管道读出的数据的存放地址;caseSend 时表示写入管道的数据存放地址

go 提供了 selectgo() 方法用于处理 select 语句

func selectgo(cas0 *scase, order0 *uint16, ncases int) (int, bool)

cas0:case 语句数组地址,长度为 ncases

order0:整型数组地址,长度为 case 个数的 2 倍。是 case 执行随机性的关键

  1. 过滤管道值为 nil 的case

  2. 生成 case 的随即顺序,保存到 pollorder 中。fastrandn()

  3. 循环遍历各个 case。管道就绪即进行读写操作

使用 select 读取管道时,尽量检查读取是否成功,以便及时发现管道异常

for-range

for-range 表达式用于遍历集合元素,比传统的 for 循环更简洁、更直观

特性

作用对象:数组、切片、string、map、channel

string

for-range 遍历过程中每次迭代均会执行一次赋值操作,在 string 类型时考虑使用下标而不是赋值,避免内存拷贝影响性能。

range 作用于 string 返回的下标可能不连续,因为 string 底层使用 unicode 编码,对于中文等一个字符占用字节数 >1。

map

range 作用于 map 时,返回每个元素的 key 和 value

map 没有顺序,遍历顺序随机。因此不要在循环过程中修改 map

channel

channel 元素没有下标的概念,最多只能返回一个元素。range 会阻塞等待 cahnnel 中的数据,直到 channel 被关闭。range 作用于值为 nil 的 channel 时,则会永久阻塞。

for idx := range array  <==> for idx, _ := range array

实现原理

for-range ——> 传统 for 循环

3. 协程

概念

协程术语:coroutine

Go 在语言层面直接提供对协程的支持称为 goroutine

基本概念

进程

进程是应用程序的启动实例,每个进程都有独立的内存空间,不同进程通过进程间的通信方式来通信。

线程

线程从属于进程,每个进程至少包含一个线程,线程是 CPU 调度的基本单位,多个线程之间可以共享进程的资源,并通过共享内存等线程间的通信方式来通信。

协程

协程可理解为一种轻量级线程,与线程相比,协程不受操作系统调度,协程调度器由用户应用程序提供,协程调度器按照调度策略把协程调度到线程中运行。Go 应用程序的协程调度器由 runtime 包提供,用户使用 go 关键字即可创建协程,这也就是在语言层面直接支持协程的含义。

协程的优势

高并发场景频繁创建和销毁线程会造成不必要的开销——>线程池

线程池由操作系统进行调度。如果线程任务发生系统调用,操作系统将线程置为阻塞状态,线程池的消费能力下降。如果大部分任务都进行系统调用,任务队列中的任务就会产生堆积。通过增加线程池中线程的数量可以一定程度解决问题,但是随着线程增多,过多线程争抢 CPU 资源,导致上下文切换开销变大。

线程池——>协程

协程工作在用户态,能大大减少上下文切换的开销。协程调度器把可运行的协程逐个调度到线程中执行,同时及时把阻塞的协程调度出线程,避免线程的频繁切换,达到少量线程实现高并发效果。

线程由操作系统调度,在内核态,系统调用时阻塞;协程由协程调度器调度,在用户态,能及时切换阻塞中的任务,提高执行效率。

多个协程分享操作系统分给线程的时间片,从而达到充分利用 CPU 算力的目的。

调度模型

线程模型

线程可分为用户线程和内核线程

  • N:1 模型,N 个用户线程运行在 1 个内核线程中,优点是用户线程上下文切换快,缺点是无法充分利用 CPU 多核算例

  • 1:1 模型,每个用户线程对应一个内核线程,优点是充分利用 CPU 算力,缺点是线程上下文切换较慢

  • M:N 模型,Go 实现模型,M 个用户线程(协程)运行在 N 个线程中。优点充分利用 CPU 算力且协程上下文切换快,缺点是模型的调度算法较为复杂

Go 调度器模型

三个关键实体

  • machine:工作线程,由操作系统调度

  • processor:处理器,包含运行 Go 代码的必要资源,也有调度 goroutine 的能力

  • goroutine:Go 协程,每个 go 关键字都会创建一个协程

M 必须持有 P 才可以执行代码,M 也会被系统调用阻塞

P 的个数在程序启动时决定,默认等同 CPU 的核数,也可以通过配置 GOMAXPROCS

export GOMAXPROCS=80

runtime.GOMAXPROCS(80)

M 的个数通常稍大于 P 的个数,因为除了运行 Go 代码,runtime 包还有其他内置任务需要处理

早期只有全局队列,处理器需要争抢锁来调度,严重影响并发执行效率,后来引入局部 runqueues,每个处理器访问自己的本地队列不需要加锁

调度策略

1. 队列轮转

G 执行完毕后,P 会调度本地队列的另一个 G 到 M 中执行,同时会周期性地查看全局队列中是否由 G 待运行将其调度到 M 中执行。避免“饿死”

2. 系统调用

M 的个数通常稍大于 P 的个数,当 G 即将进入系统调用时,M 释放 P,从冗余的 M 接管 P,继续执行 P 中剩下的 G。

3. 工作量窃取

本地队列为空——全局队列为空——偷取其他 P 的协程(一半的任务)

当某个处理器 P 没有需要调度的协程时,从其他处理器中偷取协程。

4. 抢占式调度

类似时间片轮转,调度器会监控每个协程的执行时间,一旦执行时间过长且有其他协程在等待时,会把协程暂停,转而调度等待的协程。

1.14 前在函数调用间隙检查是否可被抢占,没有函数调用时会无限占用执行权

1.14 后引入基于信号的抢占机制解决

5. GOMAXPROCS

由于旧的 M 被阻塞和新的 M 得到运行之间有一定间隔,在 I/O 密集型应用中可以把 GOMAXPROCS 的值设置大一些

4. 内存管理

Go 在某些应用场景下垃圾回收会暂时停止程序的运行,影响程序性能。

内存分配

C 语言通过内存分配器调用 malloc() 方法动态申请内存,Go 也实现了内存分配器,原理与 tcmalloc 类似,维护一块大的全局内存,每个线程(Go 中为处理器 P)维护一块小的私有内存,私有内存不足时再从全局申请。

基础概念

内存划分

  • arena:实际内存

大小 512GB,划分为一个个 page 页,每个页大小为 8KB,共有 512GB/8KB 页

  • span:存放 span 指针,对应一个或多个 page

大小 512MB,即 (512GB/8KB)*指针大小(8byte)

  • bitmap:主要用于 GC

大小 512GB

SPAN

用于管理 arena 页,span 内部划分粒度更小的块,每个 span 用于管理特定的 class 对象

// class  bytes/obj  bytes/span  objects  waste bytes
//     1          8        8192     1024            0
//     2         16        8192      512            0
//     3         32        8192      256          
//  ...
//    10        144        8192       56          128
//  ...

class:classID,span 数据结构中的 spanclass,表示该 span 可处理的对象类型

bytes/obj:该 class 代表对象的字节数

bytes/span:每个 span 占用堆的字节数,页数 x 页大小(1*8KB)

objects:每个 span 可分配的对象个数,span/obj

waste bytes:每个 span 产生的内存碎片,span%obj

  • 数据结构
type mspan struct {
    next *mspan            //链表后向指针,用于将span链接起来
    prev *mspan            //链表前向指针,用于将span链接起来
    startAddr uintptr // 起始地址,也即所管理页的地址
    npages    uintptr // 管理的页数

    nelems uintptr // 块个数,也即有多少个块可供分配

    allocBits  *gcBits //分配位图,每一位代表一个块是否已分配

    allocCount  uint16     // 已分配块的个数
    spanclass   spanClass  // class表中的class ID

    elemsize    uintptr    // class表中的对象大小,也即块大小
}

CACHE

由于 span 只能存储特定 class,是内存管理的基本单位,因此需要有用来管理 span 的结构,即 mcentral。各线程需要内存时从 mcentral 管理的 span 中申请内存,为了避免多线程申请内存不断加锁,Go 为每个线程分配了 span 的缓存,即 mcache

type mcache struct {
    alloc [67*2]*mspan // 按class分组的mspan列表
}

span 共有 67 种 calss 类型,每种 class 类型都有两组 span 列表,第一组对象包含指针,第二组对象不包含指针,这么做是为了提高 GC 扫描性能,对于不包含指针的 span 列表无需扫描。根据对象是否包含指针分为 noscan 和 scan 两类

初始化时 cache 没有任何 span,在使用过程中动态从 central 获取并缓存下来

CENTRAL

cache:线程的私有资源管理,针对单个线程

central:全局资源管理,为多个线程服务

type mcentral struct {
    lock      mutex     //互斥锁
    spanclass spanClass // span class ID
    nonempty  mSpanList // non-empty 指还有空闲块的span列表
    empty     mSpanList // 指没有空闲块的span列表

    nmalloc uint64      // 已累计分配的对象个数
}

获取 span:加锁——从 nonempty 去除可用 span,将其从链表删除——将 span 加入 empty 列表——span 给到线程——解锁——span 放入 cache

归还 span:加锁——span 从 empty 列表删除——span 加入 nonempty 列表——解锁

HEAP

每个 mcentral 对象只管理特定 class 规格的 span,每种 class 都会对应一个 mcentral,用于管理 mcentral 集合的为 mheap

type mheap struct {
    lock      mutex

    spans []*mspan // 指向 spans 区域,用于映射 span 和 page 的关系

    bitmap        uintptr     //指向bitmap首地址,bitmap是从高地址向低地址增长的

    arena_start uintptr        //指示arena区首地址
    arena_used  uintptr        //指示arena区已使用地址位置

    central [67*2]struct {
        mcentral mcentral
        pad      [sys.CacheLineSize - unsafe.Sizeof(mcentral{})%sys.CacheLineSize]byte
    }
}

mheap 管理着全部的内存,事实上 Go 就是通过一个 mheap 类型的全局变量进行内存管理的

内存分配

针对待分配对象的大小不同有不同的分配逻辑:

  • (0, 16B) 且不包含指针的对象: Tiny 分配
  • (0, 16B) 包含指针的对象:正常分配
  • [16B, 32KB] : 正常分配
  • (32KB, -) : 大对象分配 其中 Tiny 分配和大对象分配都属于内存管理的优化范畴,这里暂时仅关注一般的分配方法。

以申请 size 为 n 的内存为例,分配步骤如下:

  1. 获取当前线程的私有缓存 mcache
  2. 根据 size 计算出适合的 class 的 ID
  3. 从 mcache 的 alloc [class] 链表中查询可用的 span
  4. 如果 mcache 没有可用的 span 则从 mcentral 申请一个新的 span 加入 mcache 中
  5. 如果 mcentral 中也没有可用的 span 则从 mheap 中申请一个新的 span 加入 mcentral
  6. 从该 span 中获取到空闲对象地址并返回

mspan——mcache——mcentral——mheap 资源不足时逐级申请

垃圾回收

垃圾回收算法
  • 引用计数

每个对象维护一个引用计数,当引用对象被销毁时计数减一,计数为 0 时回收该对象

优点:对象可以很快被回收,不会出现内存耗尽或达到阈值才回收

缺点:不能很好地处理循环引用,实时维护引用计数也有一定代价

代表语言:Python、PHP、Swift

  • 标记-清除

从跟变量开始遍历所有引用的对象,引用的对象标记为”被引用“,没有标记地对象被回收

优点:解决了引用计数地缺点

缺点:需要 STW(Stop The World),即暂时停止程序运行

代表语言:Go(三色标记法)

  • 分代收集

按照对象生命周期长短划分不同的代空间,生命周期长地放入老年代,短地放入新生代,不同代有不同的回收算法和回收效率。

优点:回收性能好

缺点:算法复杂

代表语言:Java

Go 垃圾回收
  • 原理

垃圾回收的核心就是标记出被引用的内存和未被引用的内存,把未被引用的内存回收,供后续内存分配使用

垃圾回收开始时从 root 对象开始扫描,把 root 对象引用的内存标记为 “被引用”,考虑到内存块中存放的可能是指针,所以还需要递归的进行标记,全部标记完成后,只保留被标记的内存,未被标记的全部标识为未分配即完成了回收。

  • 内存标记(Mark)

span 下存在 allocBits 和 gcmarkBits,两者的数据结构完全一样,allocBits 表示内阁内存块的分配情况,gcmarkBits 标记内存块被引用的情况。

标记阶段:gcmarkBits 对每块内存进行标记,有对象引用标记为 1,没有标记为 0

标记结束:allocBits 指向 gcmarkBits,标记为 1 的才是存活的内存块

  • 三色标记法

需要一个标记队列对标记对象进行存放,按照顺序对每个对象进行标记

灰色:对象还在标记队列中等待

黑色:对象已被标记,gcmarkBits 对应的位为 1(不会在本次 GC 中被清理)

白色:对象未被标记,gcmarkBits 对应的位为 0(对象会在本次 GC 中被清理)

  • STW

为了避免回收过程中指针传递引起内存引用关系的变化,GC 时停止所有的 goroutine

STW 时间的长短直接影响了应用的执行

垃圾回收优化
  • 写屏障(Write Barrier)

写屏障类似一种开关,在 GC 特定时机开启,开启后指针传递时会标记指针,本轮不回收。

写屏障可以让 goroutine 与 GC 同时运行。虽然写屏障不能完全消除 STW,但可以大大缩短 STW 的时间

  • 辅助 GC(Mutator Assist)

为了防止内存分配过快,在 GC 执行过程中,如果 goroutine 需要分配内存,那么这个 goroutine 会参与一部分 GC 的工作,即帮助 GC 做一部分工作,这个机制叫作 Mutator Assist。

垃圾回收触发时机
  • 内存分配量达到阈值

阀值 = 上次GC内存分配量 * 内存增长率

内存增长率由环境变量 GOGC 控制,默认为 100,即每当内存扩大一倍时启动 GC。

  • 定期出发 GC

默认情况下,最长 2 分钟触发一次 GC

// src/runtime/proc.go:forcegcperiod
var forcegcperiod int64 = 2 * 60 * 1e9
  • 手动触发

使用 runtime.GC() 手动触发,主要用于 GC 的性能测试和统计。

GC 性能优化

GC 性能与对象数量负相关,对象越多 GC 性能越差

优化思路

  • 减少对象分配的个数,比如对象复用或使用大对象组合多个小对象等

  • 注意内存逃逸现象

逃逸分析

所谓逃逸分析(Escape analysis)是指由编译器决定内存分配的位置,不需要程序员指定。 函数中申请一个新的对象

  • 如果分配在栈中,则函数执行结束可自动将内存回收;
  • 如果分配在堆中,则函数执行结束可交给 GC(垃圾回收)处理;

逃逸分析还跟闭包息息相关

逃逸策略

在函数中新申请对象时,编译器会自动决定是否逃逸

  • 函数外部没有引用,优先放到栈中,执行结束自动内存回收

  • 函数外部存在引用,必定放到堆中,由 GC 处理

仅在函数内部使用的变量也有可能放到堆中,比如内存过大超过栈的存储能力。

逃逸场景
  • 指针逃逸
package main

type Student struct {
    Name string
    Age  int
}

func StudentRegister(name string, age int) *Student {
    s := new(Student) //局部变量s逃逸到堆

    s.Name = name
    s.Age = age

    return s
}

func main() {
    StudentRegister("Jim", 18)
}
go build -gcflags=-m

# .\main.go:9: new(Student) escapes to heap
  • 栈空间不足逃逸
package main

func Slice() {
    s := make([]int, 1000, 10000)

    for index, _ := range s {
        s[index] = index
    }
}

func main() {
    Slice()
}
  • 动态类型逃逸

很多函数参数为 interface 类型,比如 fmt.Println (a …interface {}),编译期间很难确定其参数的具体类型,也会产生逃逸。

package main

import "fmt"

func main() {
    s := "Escape"
    fmt.Println(s)
}

s escapes to heap

  • 闭包引用对象逃逸
func Fibonacci() func() int {
    a, b := 0, 1
    return func() int {
        a, b = b, a+b
        return a
    }
}
小结
  • 栈上分配内存比堆中分配内存有更高的效率

  • 栈上分配的内存不需要 GC 处理

  • 堆上分配的内存使用完毕会交给 GC 处理

  • 逃逸分析的目的是决定分配地址是栈还是堆

  • 逃逸分析在编译阶段完成

函数传递指针真的比传值效率高吗?

传递指针可以减少底层值的复制,但如果复制的数据量小,由于指针传递会产生逃逸,可能会使用堆,也可能增加 GC 的负担,所以传递指针不一定是高效的。

——>大对象用指针,小对象用值

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值