Go专家编程 全书笔记

第一章 常见数据结构的实现原理

管道
  • 关闭不再需要使用的 channel 并不是必须的。跟其他资源比如打开的文件、socket 连接不一样,这类资源使用完后不关闭后会造成句柄泄露,channel 使用完后不关闭也没有关系,channel 没有被任何协程用到后最终会被 GC 回收。关闭 channel 一般是用来通知其他协程某个任务已经完成了。
  • 对于值为nil的管道,无论读写都会阻塞,而且是永久阻塞。
  • 向关闭的管道写入数据会触发panic,但可以读。
  • 实现
    • 从管道读取数据时,如果管道缓冲区为空或者没有缓冲区。则当前协程会阻塞,并被加入recvq队列。如果等待发送队列sendq不为空,且没有缓冲区,那么此时直接从sendq队列的第一个协程中获取数据。
    • 向管道写入数据时,如果管道缓冲区已满或没有缓冲区,则当前协程会被阻塞,并被加入sendq队列。如果接收队列不为空时,说明缓冲区中没有数据但有协程在等待数据,此时会把数据直接传递给recvq队列中的第一个协程,而不必再写入缓冲区。
    • 一般情况下recvq和sendq至少有一个为空。只有一个例外,那就是同一个协程使用select语句向管道一边写入数据,一边读取数据,此时协程会分别位于两个等待队列中。
    • 关闭管道时会把recvq中的协程全部唤醒,这些协程获取数据都为对应类型的零值。同时会把sendq队列中的协程全部唤醒,而这些协程会触发panic。
    • 触发panic的操作:
      • 关闭值为nil的管道;
      • 关闭已经关闭的管道;
      • 向已经关闭的管道写入数据。
    • select语句的多个case语句的执行顺序是随机的。
slice
  • slice称为动态数组,依托数组实现,底层数组对用户屏蔽,在底层数组容量不足时可以实现自动重分配并生成新的slice
  • 使用数组创建slice时,slice将与原数组共用一部分内存。
  • 扩容:如果原slice的容量小于1024,则新的slice容量将扩大为原来的2倍;如果原slice的容量大于或等于1024,则新slice的容量将扩大为原来的1.25倍。
  • 使用copy()内置函数拷贝两个切片时,会将源切片的数据逐个拷贝到目的切片指向的数组中,拷贝数量取两个切片长度的最小值。
  • 通过函数传递切片时,不会拷贝整个切片,因为切片本身只是一个结构体而已
  • 切取字符串获取新的字符串;切取数组共享底层数组。可以限制容量,a[low,high,max],容量为max-low。
map
// A header for a Go map.
type hmap struct {
	
	count     int // # live cells == size of map.  Must be first (used by len() builtin)
	flags     uint8
	B         uint8  // log_2 of # of buckets (can hold up to loadFactor * 2^B items)
	noverflow uint16 // approximate number of overflow buckets; see incrnoverflow for details
	hash0     uint32 // hash seed

	buckets    unsafe.Pointer // array of 2^B Buckets. may be nil if count==0.
	oldbuckets unsafe.Pointer // previous bucket array of half the size, non-nil only when growing
	nevacuate  uintptr        // progress counter for evacuation (buckets less than this have been evacuated)

	extra *mapextra // optional fields
}
  • 并发读写:map操作不是原子的。这意味着多个协程同时操作map时有可能产生读写冲突,读写冲突会触发panic从而导致程序退出。
  • 采用bucket的数组结构存储数据,每个bucket可以存储8个键值对,多余8个用overflow指针指向下一个bucket
  • 扩容条件
    • 负载因子大于6.5时,即平均每个bucket存储的键值对达到6.5个以上
    • overflow的数量大于2^15,即overflow数量超过32768
  • 采用增量扩容,每次访问map都会触发一次搬迁,每一次搬迁两个键值对。类似于redis的rehash
  • 等量扩容:bucket不变,重新做一遍类似于增量扩容的搬迁动作,把松散的键值对重新排列一次,以使bucket的使用率更高,进而保证更快的存取速度。进过组织后,overflow的bucket数量减少。
  • 查找过程:
    • 根据key值计算HASH值;
    • 取Hash值低位与hash.B取模来确定bucket的位置
    • 取Hash值的高位在tophash数组中查询。
    • 如果tophash[i]中存储的Hash值与当前key的Hash值相等,则获取tophash[i]的key值进行比较;
    • 当前bucket中没有找到,则依次从溢出的bucket中查找。
    • 如果当前map处于搬迁中,那么查找从oldbuckets数组中查找
  • 添加过程
    • 根据key值计算HASH值;
    • 取Hash值低位与hash.B取模来确定bucket的位置
    • 查找该key是否已经存在,如果存在则直接更新值
    • 如果该key不存在,则从该bucket中寻找空余位置并插入
    • 如果当前map处于搬迁中,那么新元素会直接添加到新的buckets数组中,但查找过程仍从oldbuckets数组中开始、
  • 删除过程:查找元素,存在则从相应的bucket中清除,不存在则什么都不做。
iota
// iota 代表的是行数索引
const (
	a1, a2 = 1 << iota, 1<<iota - 1 // 1 0
	b1, b2 = 1 << iota, 1<<iota - 1 // 2 1
	c1, c2 = 1 << iota, 1<<iota - 1 // 4 3
	_, _
	_, _
	d1, d2 = 1 << iota, 1<<iota - 1 // 32 31
)
  • iota标识符仅能用于常量声明语句中,它的取值与常量声明块中的代码的行数强相关,可以说它标识的正是常量声明语句中的行数
  • 单个const声明块中从0开始
  • 单个const声明块中,每增加一行声明,iota的取值增1,即便声明中没有使用iota也是如此
  • 单行声明语句中,即便出现多个iota,iota的取值也保持不变。
string
  • 字符串拼接:触发内存分配和内存拷贝,单行语句拼接多个字符串只分配一次内存。
  • 类型转换:无论是字符串转[]byte,还是[]byte转字符串,都将发生一次申请内存和内存拷贝,会有一定的开销
  • 字符串值不可修改,可用for-range遍历字符串.UTF-8编码
for k,v := range "中国"{
	fmt.Printf("index:%d val:%c\n",k,v)
}
// index:0 val:中
// index:3 val:国

  • string是runtime包中的stringStruct类型,对外呈现出string类型
type stringStruct struct{
	str unsafe.Pointer
	len int
}
  • 编译优化:由于只是临时把byte切片转换成string,也就避免了因切片内容修改而导致string数据变化的问题,所以此时可以不必拷贝内存。
    • 使用m[string(b)]来查找map(map的key的类型是string时,临时把切片b转换成string)
    • 字符串拼接,如 <"+ “string(b)” + ">
    • 字符串比较:string(b) == “foo”

第二章 控制结构

select
  • select只能作用于管道,包括数据读取和写入。
  • 当select的多个case语句中的管道均阻塞时,整个select语句也会陷入阻塞(没有default语句的情况下),直到任意一个管道解除阻塞。如果多个case都没有阻塞,那么select将随机挑选一个case执行。
  • 空select永久阻塞 select{}
  • 实现原理
    • select中的case对应于runtime包中的scase数据结构,由于每个scase中仅能存放一个管道,这就直接决定了每个case语句仅能处理一个管道。
    type scase struct{
        c *hchan              // 操作的管道
        kind uint16           //case类型
        elem unsafe.Pointer   // data element
    }
    
    • go在运行包中提供了selectgo()方法用于处理select语句。
    func selectgo(case0 *scase,order0 *unit16,ncases int)(int ,bool)
    
    • order0用于存放管道遍历顺序,先做随机乱序处理再遍历。所以多个case语句的执行顺序是随机的。
    • 存在default语句,select将不会阻塞。
for-range
  • 可以用于遍历所有的集合类型,包括数组、切片、string、map、channel
  • 由于map的数据结构本身没有顺序的概念,它仅仅存储key-value对,所以range分别返回key和value。另外,如果遍历过程中修改map(增加或删除元素),则range行为是不确定的,删除的元素不可能被遍历到,新加的元素可能遍历不到,总之尽量不要在循环中修改map
  • 对于数组和切片,循环次数在循环开始前就已经确定为数组或切片长度,所以在循环中,数组或切片新添加的元素是无法被遍历到的。
  • for-range作用于channel时,没有元素会阻塞,如果channel关闭,则会退出。

第三章 协程

  • 进程:应用程序的启动实例,每个进程都有独立的内存空间,不同进程通过进程间的通信方式来通信
  • 线程:线程从属于进程,每个进程至少包含一个线程,线程是CPU调度的基本单位,锁让线程之间可以共享进程的资源并通过共享内存等线程间的通信方式来通信
  • 协程:可以理解为一种轻量级线程,与线程相比,协程不受操作系统调度,协程调度器由用户应用程序提供,协程调度器按照调度策略把协程调度到线程中运行。Go应用程序的协程调度器由runtime包提供,用户使用go关键字即可创建协程,这就是在语言层面直接支持协程的含义。
  • 协程的优势:
    • 使用线程池技术可以减少频繁创建线程造成的不必要的开销。但是线程发生系统调用时,则操作系统会将该线程置为阻塞状态,从而工作线程减少。增加线程又会导致争抢CPU资源,消费能力会有上限。
    • 过多的线程会导致上下文切换的开销变大,而工作在用户态的协程则能大大减少上下文切换的开销。
    • 协程调度器把可运行的协程逐个调度到线程中执行,同时及时把阻塞的协程调度出线程,从而有效地避免了线程的频繁切换,达到了使用少量线程实现高并发的效果。
  • 线程模型,用户线程:内核线程
    • N:1,N个用户线程一个内核线程,优点是用户线程上下文切换快,缺点是无法充分利用CPU多核的算力
    • 1:1,每个用户线程对应一个内核线程,优点是充分利用CPU的算力,缺点是线程上下文切换慢
    • Go实现的是M:N模型,M个用户线程,N个内核线程,优点是充分利用CPU的算力且协程上下文切换快,缺点则是该模型的调度算法较为复杂。
  • Go调度器模型包含三个关键实体
    • M(machine):工作线程,它由操作系统调度
    • P(processor):处理器(go定义的一个概念,不是指CPU),包含运行Go代码的必要资源,也有调度goroutine的能力。
    • G(goroutine):即Go协程,每个go关键字都会创建一个协程
  • M必须持有P才可以执行代码,跟系统的其他线程一样,M也会被系统调用阻塞。P的个数在程序启动时决定,默认情况下等同于CPU的核数,可以使用runtime.GOMAXPROCS()设置
  • 每个处理器P都拥有一个runqueues队列,此外还有一个全局runqueues队列,由多个处理器共享。
  • 一般来说,处理器P的协程G额外创建的协程会加入本地runrueues中,但如果本地队列已满,或者阻塞的协程被唤醒,则协程会被放入全局的runqueues中,处理器P除了调度本地的runqueues中的协程,还会周期性地从全局runqueues中摘取协程来调度。
  • 调度策略
    • 队列轮转:每个处理器P维护一个协程G的队列,处理器P依次将G调度到M中执行。同时,每个P会周期性地查看全局队列是否有G待运行并将其调度到M中执行,全局队列G主要来自从系统调用中恢复的G。
    • 系统调用:当协程G0发起系统调用时,如果被阻塞,M0释放P,和它关联的M0将和持有G0陷入睡眠,某个冗余M1将获取P,继续执行队列剩下的G。M1来源于缓存池,也可能新建。当G0结束系统调用时,根据M0是否能获得P,对G0进行不同的处理:
      • 如果有空闲的P,则获取一个P,继续执行G0。
      • 如果没有空闲的P,则将G0放入全局队列,等待被其他的P调度。然后M0将进入缓存池睡眠
    • 工作量窃取:通过go关键字创建的协程通常会优先放到当前协程对应的处理器队列中,可能有些协程自身不断地派生新的协程,而有些不派生。如此一来,多个处理器P维护的G队列可能不均衡。为此Go调度器提供了工作量窃取策略,即当处理器P没有需要调度的协程时,将从其他处理器中偷取协程。先查询全局队列,如果没有,则从另一个正在运行的处理器P中偷取协程,每次一般。
    • 抢占式调度:避免某个协程长时间执行,而阻碍其他协程被调度的机制。调度器会监控每个协程的执行时间,一旦执行时间过长且有其他协程在等待时,会把协程暂停,转而调度等待的协程,已达到类似时间片轮转的效果。
    • GOMAXPROCS对性能的影响:一般来说,GOMAXPROCS的大小设置为CPU的核数。对于I/O密集型应用,可以设置的大一点。理论上当某个goroutine进入系统调用时,会有一个新的M被启用或创建,继续占满CPU。但由于Go调度器检测到M被阻塞是有一定延迟的,即旧的M被阻塞和新的M得到运行之间是有一定间隔的。

第四章 内存管理

内存分配
  • 预申请内存划分为spans、bitmap、arena三部分。其中arena即所谓的堆区,应用中需要的内存从这里分配,spans和bitmap是为了管理arena而存在的。arena区域按页划分,每页8K。
  • span:用于管理arena页的关键数据,每个span中包含一个或多个连续页。为了满足小对象分配,会将span中的页划分为更小的粒度,而对于大对象比如超过页大小,则通过多页实现。
    • class:根据对象大小,划分了一系列class,每个class都代表一个固定大小的对象,以及每个span的大小
    • span是内存管理的基本单位,每个span用于管理特定的class对象,根据对象大小,span将一个或多个页拆分为多个块处理。
  • central:[]mcentral。全局资源,为多个线程服务,当某个线程内存不足时会向central申请,当某个线程释放内存时又会回收进central。每个mcentral只管理一种class。
  • cache:各线程需要内存时从central管理的span中申请内存,为了避免多线程申请内存时不断的加锁,go为每个线程分配了span的缓存,这个缓存就是cache
    • cache在初始化时没有任何span,在使用中会动态地从central中获取并缓存下来。
    • cache作为线程的私有资源为单个线程服务,而central则是全局资源,为多个线程服务
  • heap:由于每个mcentral只管理一种类型的class,这个mcentral的对象集合存放在heap里。go就是通过一个mheap类型的全局变量管理内存的。
垃圾回收
  • 常见垃圾回收算法

    • 引用计数:对每个对象维护一个引用计数,当引用对象的对象被销毁时,引用计数减1,当引用计数器为0时回收该对象。
      • 优点:对象可以很快被回收,不会出现内存耗尽或达到某个阈值时才回收
      • 缺点:不能很好地处理循环引用,而且实时维护引用计数也有一定的代价
      • 代表语言:Python PHP Swift
    • 标记——清除:从根变量开始遍历所有引用的对象,引用的对象标记为"被引用",没有标记的对象被回收
      • 优点:解决了引用计数的缺点
      • 缺点:需要STW,即暂时停止程序运行
      • 代表语言:Go(其采用三色标记法)
    • 分带收集:按照对象生命周期的长短划分不同的代空间,生命周期长的放入老年代,而短的放入新生代,不同代有不同的回收算法和回收频率
      • 优点:回收性能好
      • 缺点:算法复杂
      • 代表:java
  • 垃圾回收原理:标记出哪些内存还在使用中(即被引用到),哪些内存不再使用(即未被引用),把未引用的内存回收,以供后续内存分配时使用。

    • 垃圾回收开始从root对象扫描,把root对象引用的内存标记为"被引用",考虑到内存块中存放的可能是指针,所以还需要递归地进行标记,全部标记完成后,只保留被标记的内存,未被标记的内存全部标记为未分配即完成了回收。
  • 内存标记:mspan中gcmarkBits记录每块内存的标记情况,allocBits记录每块内存的分配情况。它们的数据结构完全一样,标记结束就是内存回收,回收时将allocBits指向gcmarkBits,代表标记过的内存才是存活的,gcmarkBits则会在下次标记时重新分配。

  • 三色标记法:标记队列存放待标记的对象

    • 灰色:对象还在标记队列中等待
    • 黑色:对象已被标记,gcmarkBits对应的位为1(该对象不会在本次GC中被清理)
    • 白色:对象未被标记,gcmarkBits对应的位为0(该对象会在本次GC中被清理)
    1.有对象A B C D E F 。B引用了D,root对象a,b 引用了A,B
    2.开始这6个对象都在白色队列里,开始扫描a,b,由于引用了A,B,那么A,B变为灰色对象。
    3.接下来分析灰色对象,A没有引用其他对象,很快变为黑色对象,B引用了D,则B转入黑色的同时还需要将D转为灰色对象进行接下来的分析。
    4.分析D,由于D没有引用其他对象,所以D转为黑色对象,标记过程结束。
    5.最终,黑色对象会被保留下来,白色对象会被回收
    
  • go的STW(Stop The World)就是停止所有的goroutine,专心做垃圾回收,待垃圾回收后再恢复goroutine执行

  • 垃圾回收优化:

    • 写屏障:STW的目的是防止GC扫描时 内存变化而停止goroutine,而写屏障就是让goroutine与GC同时运行的手段,写屏障类似一种开关,在GC的特定时机开启,开启后指针传递会标记指针,即本轮不回收,下次GC时再确定。
    • 辅助GC:为了防止内存分配过快,在GC执行过程中,如果goroutine需要分配内存,那么该goroutine会参与一部分GC的工作,即帮助GC做一部分工作,这个机制就叫辅助GC
  • 垃圾回收触发:

    • 内存分配量达到阈值触发GC。阈值 = 上次GC内存分配量 * 内存增长率。内存增长率由环境变量GOGC控制,默认为100,即当内存扩大一倍启动GC
    • 定期触发GC:默认最长2分钟触发一次
    • 手动触发:runtime.GC()
  • GC性能优化:GC性能与对象数量负相关,对象越多GC性能越差。GC性能优化的思路之一就是减少对象分配的个数,比如复用对象或使用大对象组合多个小对象

    • 由于内存逃逸现象会产生一定的隐式的内存分配,也有可能成为GC的负担
逃逸分析
  • 逃逸分析(Escape analysis)是指由编译器决定内存分配的位置,不需要程序员指定。在函数中申请一个新对象:

    • 如果分配在栈中,则函数执行结束后可自动将内存回收
    • 如果分配在堆中,则函数执行结束后可交给GC处理。
  • 逃逸策略:

    • 如果函数外部没有引用,则优先放在栈中
    • 如果函数外部存在引用,则必定放在堆中
    • 对于仅在函数内部使用的变量,也可能在堆中,比如内存过大超过栈的存储能力
  • 逃逸场景

    • 指针逃逸:Go返回局部变量指针
    go build -gcflags=-m
    ./study.go:8:6: can inline StudentRegister
    ./study.go:15:6: can inline main
    ./study.go:16:17: inlining call to StudentRegister
    ./study.go:8:22: leaking param: name
    ./study.go:9:10: new(Student) escapes to heap
    ./study.go:16:17: new(Student) does not escape
    
    type Student struct {
        Name string
        Age int
    }
    
    func StudentRegister(name string,age int) *Student {
        s := new(Student)
        s.Name = name
        s.Age = age
        return s
    }
    
    func main()  {
        StudentRegister("Jim",19)
    }
    
    • 栈空间不足逃逸
    // 1000长度太短没有逃逸,10000发生了逃逸
    $ go build -gcflags=-m
    ./study.go:12:6: can inline main
    ./study.go:6:11: make([]int, 1000, 1000) does not escape 
    $ go build -gcflags=-m
    ./study.go:12:6: can inline main
    ./study.go:6:11: make([]int, 1000, 10000) escapes to heap
    
    func Slice()  {
        s := make([]int,1000,10000)
        for i := range s{
            s[i] = i
        }
    }  
    func main()  {
        Slice()
    }
    
    • 动态类型逃逸:很多函数的参数为interface类型,编译期间很难确定其参数的具体类型,也会产生逃逸
    $ go build -gcflags=-m
    ./study.go:7:13: inlining call to fmt.Println
    ./study.go:7:13: s escapes to heap
    ./study.go:7:13: []interface {}{...} does not escape
    <autogenerated>:1: .this does not escape
    
    func main()  {
        s := "Escape"
        fmt.Println(s)
    }
    
    • 闭包引用对象逃逸。如下的a,b由于闭包左右,不得不将二者放到堆中。
    $ go build -gcflags=-m
    ./study.go:7:9: can inline fib.func1
    ./study.go:15:13: inlining call to fmt.Printf
    ./study.go:6:2: moved to heap: a
    ./study.go:6:4: moved to heap: b
    ./study.go:7:9: func literal escapes to heap
    ./study.go:15:26: f() escapes to heap
    ./study.go:15:13: []interface {}{...} does not escape
    <autogenerated>:1: .this does not escape
    
    func fib()func()int{
        a,b := 0,1
        return func() int {
            a,b = b,a+b
            return a
        }
    }
    func main()  {
        f:= fib()
        for i:=0;i<10;i++{
            fmt.Printf("fib:%d\n",f())
        }
    }
    
  • 指针传递可以减少底层值的复制,可以提高效率,但是如果复制的数据量小,由于指针传递会产生逃逸,则可能会使用堆,也可能增加GC的负担,所以传递指针不一定是高效的

  • 栈上分配内存比在堆上分配内存有更高的效率。

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

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

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

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

第五章 并发控制

Channel
  • 使用channel控制子协程。下面通过创建N个channel来管理N个协程,每个协程都有一个channel用于跟父协程通信,父协程创建完所有协程后等待所有协程结束。
package mm
import (
	"fmt"
	"time"
)
func main(){
	channels := make([]chan int,10)
	for i:=0;i<10;i++{
		channels[i] = make(chan int)
		go func(ch chan int) {
			// do some work
			time.Sleep(time.Second)
			 ch<-1
		}(channels[i])
	}
	
	for i,ch:= range channels{
		<-ch
		fmt.Println("Routine",i,"quit!")
	}
}
WaitGroup
  • 使用信号量机制控制子协程。子协程个数可动态调整
  • 实现
type WaitGroup struct{
    state1 [3]uint32
}
  • state1包含两个计数器,state和一个信号量,而state实际上是两个计数器counter和waiter count。
    • counter:当前还未执行结束的goroutine计数器;
    • waiter count:等待goroutine-group结束的goroutine数量,即有多少个等待者
    • semaphore:信号量
  • 对外提供三个接口
    • Add(delta int):将delta值加到counter中,delta可能为负值,所以counter可能为0或负值,负值会触发panic;当counter为0时,根据waiter数值释放等量的信号量,把等待的goroutine全部唤醒。
    • Wait():waiter递增1,并阻塞等待信号量semaphore。
    • Done():counter递减1,counter为0时按照waiter数值释放相应次数的信号量。其实就是Add(-1)
  • 编程Tips
    • Add()操作必须早于Wait(),否则会触发panic
    • Add()设置的值必须与实际等待的goroutine的个数一致,否则会触发panic。
Context
  • 使用上下文控制子协程。优点是对子协程派生出来的孙子协程的控制
  • cancelCtx实现了Context接口,通过WithCancel创建cancelCtx实例。WithCancel做三件事
    • 初始化一个cancelCtx实例
    • 将cancelCtx实例添加到其父节点的children中(如果父节点也可以被"cancel")
      • 如果父节点也支持cancel,也就是说父节点肯定有children成员,那么把新context添加到children中即可
      • 如果父节点不支持cancel,则继续向上查询,直到找到一个支持cancel的节点,把新context添加到children中
      • 如果所有的父节点均不支持cancel,则启动一个协程等待父节点结束,再把当前context结束。
    • 返回cancelCtx实例和cancel()方法
  • timerCtx实现了Context接口,通过WithDeadline()和WithTimeout()创建timerCtx实例。WithDeadline()方法步骤
    • 初始化一个timerCtx实例
    • 将timerCtx实例添加到其父节点的children中,如果父节点也可以被"cancel"
    • 启动定时器,定时到期会自动"cancel"本context。
    • 返回timerCtx实例和cancel()方法
    • 也就是说。timeCtx类型的context不仅支持手动cancel,也会在定时器到来后自动"cancel"
  • valueCtx实现了Context接口,通过WithValue()创建valueCtx实例
    • valueCtx的子context可以查询到父节点的value值
    • valueCtx不支持cancel,也就是说ctx.Done()永远无法返回,如果需要返回,则需要创建context时指定一个可以cancel的context作为父节点
  • 三种context实例可互为父节点,从而组合成不同应用形式。
Mutex
  • 为一结构体,对外暴露Lock()和Unlock()两个方法,分别用于加锁和解锁。
type Mutex struct{
    state   int32 // 互斥锁的状态,按位分为4段 依次为waiter,starving,woken,locked
    sema    uint32
}
  • state字段
    • Locked:表示Mutex是否已被锁定,0表示没有锁定,1表示已被锁定
    • Woken:表示是否有协程已被唤醒,0表示没有协程被唤醒,1表示已有协程唤醒,正在加锁过程中。
    • Starving:表示Mutex是否处于饥饿状态,0表示没有饥饿,1表示饥饿状态,说明有协程阻塞了超过1ms
    • Waiter:表示阻塞等待锁的协程个数,协程解锁时根据此值来判断是否需要释放信号量。
  • 协程之间的抢锁实际上是争抢给Locked赋值的权利,能给Locked域置1,就说明抢锁成功。抢不到就阻塞等待Mutex.sema信号量,waiter计数器加1,一旦持有锁的协程解锁,那么等待的协程会依次被唤醒
  • 协程解锁过程:先把Locked位置0,再查看到Waiter>0,就释放一个信号量,唤醒一个阻塞的协程,被唤醒的协程把Locked位置1,于是协程B获得锁。
  • 自旋过程:加锁时,如果当前的Locked位为1,则说明当前该锁被其他协程持有,尝试加锁的协程并不马上转入阻塞,而是会持续地探测Locked位是否变为0,这个过程为自旋过程。
  • 自旋的好处是,当加锁失败时不必立即转入阻塞,有一定机会获取到锁,这样可以避免协程的切换。
  • 自旋对应于CPU的PAUSE指令,CPU对该指令什么都不做,相当于CPU空转,对程序而言相当于"sleep"了一小段时间,时间非常短,当前实现是30个时钟周期。
  • 自旋会产生饥饿问题,如果加锁的协程非常多,每次都通过自旋获得锁,那么之前被阻塞的进程将很难获得锁。
  • Mutex的模式:
    • Normal模式,默认模式。在该模式下,协程如果加锁不成功则不会立即转入阻塞队列,而是判断是否满足自旋的条件,如果满足则会启动自旋过程,尝试抢锁。
    • Starving模式,自选过程中能抢到锁,一定意味着同一时刻有协程释放了锁。当释放锁的时候,如果发现有阻塞等待的协程,那么还会释放一个信号量来唤醒一个等待协程,被唤醒的协程得到CPU后开始运行,此时发现锁已经被抢占了,自己只好再次阻塞,不过阻塞前会判断自上次阻塞到本次阻塞经过了多长时间,如果超过了1ms,则会将Mutex标为"饥饿"模式,然后阻塞。
    • 在"饥饿"模式下,不会启动自旋过程,即一旦协程释放了锁,那么一定会唤醒协程,被唤醒协程将成功获取锁,同时把等待计数器waiter减1。
  • Woken状态:用于加锁和解锁过程中的通信。举个例子,同一时刻,两个协程一个在加锁,另一个在解锁,在加锁的协程可能在自旋过程中,此时把Woken标记为1,用于通知解锁协程不必释放信号量了。
  • tips
    • 使用defer避免死锁:加锁后立即使用defer对其解锁,可以有效地避免死锁。
    • 加锁和解锁应该成对出现:加锁和解锁最好出现在同一层次的代码块中,比如同一个函数。重复解锁会触发panic,应避免这种操作的可能性。
RWMutex
type RWMutex struct{
	w           Mutex
	writerSem   uint32      // 写阻塞等待的信号量,最后一个读者释放锁时会释放信号量
	readerSem   uint32      // 读阻塞的协程等待的信号量,持有写锁的协程释放锁后会释放信号量
	readerCount int32       // 记录读者个数
	readerWait  int32       // 记录写阻塞时的读者个数
}
  • Lock()的实现逻辑
    • 获取互斥锁。多个写锁定操作在此排队
    • 阻塞等待所有读操作结束。通过readCount>0 判断
    • readCount减去2^30,变为负数
  • UnLock()
    • 唤醒因读锁定而阻塞的协程。包括读操作和写操作
    • 解除互斥锁。
  • RLock()
    • 增加读操作计数,即readerCount++
    • 阻塞等待写操作(通过readCount<0 来判断是否有写锁定)结束。【写操作结束后会唤醒所有因读锁定而阻塞的协程】
  • RUnLock()
    • 减少读操作计数,即readerCount–
    • 当最后一个协程解除读锁定是,唤醒等待写操作的协程。
  • 写操作不会被"饿死":写操作到来时,会把readerCount的值复制到readerWait中,用于标记排在写操作前面的读者个数。前面的读操作结束后,除了会递减readerCount的值,还会递减readerWait的值,当readerWait的值变为0时唤醒写操作。所以,写操作相当于把一段连续的读操作划分为两部分,前面的读操作结束后唤醒写操作,写操作结束后唤醒后面的读操作。

第六章 反射

  • 使用"=="操作符可以比较两个结构体变量,但仅限于结构体成员类型为简单类型,不能包含诸如slice、map等不可比较类型。实际项目中使用reflect.DeepEqual()函数来比较结构体,数组,接口等
  • 反射对象:reflect.Type和reflect.Value
  • 反射定律:
    • 第一定律:反射可以将interface类型变量转换成反射对象
    • 第二定律:反射可以将反射对象还原成interface对象
    • 第三定律:反射对象可修改,value值必须是可修改的

第七章 测试

  • 子测试:在一个测试中执行多个测试的能力。t.Run(“A=1”,sub1)
  • 子测试并发
// 把所有子测试放在一个组里,可以保证其下的所有子测试执行完成再返回
// 如果子测试没有group里,则t.Log("junmo")最先执行
func TestStudy(t *testing.T) {
    t.Log("Csq") // setup
    
	t.Run("group", func(t *testing.T) {
		t.Run("A=1",t1)
		t.Run("A=2",t2)
		t.Run("A=3",t3)
	})

	t.Log("junmo") // tear-down
}

func t1(t *testing.T)  {
	t.Parallel()
	time.Sleep(time.Second)
	t.Log("t1",time.Now().Unix())
}

func t2(t *testing.T)  {
	t.Parallel()
	time.Sleep(2*time.Second)
	t.Log("t2",time.Now().Unix())
}
func t3(t *testing.T)  {
	t.Parallel()
	time.Sleep(3*time.Second)
	t.Log("t3",time.Now().Unix())
}
  • Main测试:TestMain用于主动执行各种测试,可以在测试前后做setup和tear-down操作
func TestMain(m *testing.M) {
	println("TestMain setup.")
	retCode := m.Run() // 执行测试,包括单元测试、性能测试和示例测试
	println("TestMain tear-down.")
	os.Exit(retCode)
}
  • 接口定义中如果存在私有接口(小写字母开头),则可以控制其他模块代码不能实现该接口,保证代码和其他模块不会冲突

  • 测试参数

    • -run 过滤筛选
    • -args 控制编译的参数
    func TestArgs(t *testing.T) {
        if !flag.Parsed(){
            flag.Parse()
        }
        t.Log(flag.Args())
    }
    go test -v study*  -run Args  -args "csq" "junmo"
    study_test.go:139: [csq junmo]
    
    • -json 测试结果转换为json输出,以便在自动化测试时使用
    • -o 指定生成二进制可执行文件,并执行测试
    • -bench regexp:过滤benchmark
    • -benchtime s:执行秒数
    • -cpu 1,2,4 :分别以1个cpu执行一次 2个cpu各执行一次 4个cpu各执行一次
    • -count n :执行次数
    • -failfast:指定如果发现错误,立即停止测试
    • -list regexp:只列出匹配成功的测试函数,并不真正执行,而且不会列出子函数
    • -parallel n :指定测试最大并发数
    • -run regexp:过滤单元测试和示例测试
    • -timeout d:设置超时时间,默认10min
    • -v:打印详细的日志
    • -benchmem:打印每个操作分配的字节数和每个操作分配的对象数
  • benchstat

# 安装 $GOPATH/bin
go get golang.org/x/perf/cmd/benchstat
# 基准测试 分别测试1核 2核 4核CPU,每个测试4次
go test -bench=. -cpu=1,2,4 -count=4 study* > bench.before
# 分析测试结果
$GOPATH/bin/benchstat bench.before

name  time/op
T1    6.60ns ± 1%
T1-2  6.63ns ± 1%
T1-4  6.59ns ± 2%
T2    4.69ns ± 1%
T2-2  4.70ns ± 1%
T2-4  4.74ns ± 2%

第八章 错误处理

error
  • 通过扩展fmt.Errorf()来支持创建链式的error,并通过error.Unwrap()来拆解error。
  • errors.Is()递归的拆解error并检查是否是指定的error值。
  • errors.As()递归地拆解error并检查是否是指定的error类型,如是则将error写入指定的变量中。
package main
import (
  "errors"
  "fmt"
)
type CsqErr struct {
	Name string
	Address string
	err error
}
func (c *CsqErr)Error() string {
	return fmt.Sprintf("name:%s address:%s err:%s",c.Name,c.Address,c.err)
}
func main() {
	err := errors.New("demo error")
	err1 := fmt.Errorf("wrap err1,%w",err)
	err2 := fmt.Errorf("wrap err2,%w",err1)
	err3 := fmt.Errorf("wrap err3,%w",err2)

	fmt.Println(errors.Unwrap(err)) // <nil>
	fmt.Println(errors.Unwrap(err3)) // wrap err2,wrap err1,demo error
	fmt.Println(errors.Is(err3,err),errors.Is(err3,err1)) // true
	
	err4 := &CsqErr{
		Name:    "陆雪琪",
		Address: "小竹峰",
		err:     err,
	}
	err5 := fmt.Errorf("err5 prefix: %w",err4)
	var target *CsqErr
	if errors.As(err5, &target){
		fmt.Println("target:",target) // target: name:陆雪琪 address:小竹峰 err:demo error
	}
}
defer
  • defer语句用于延迟函数的调用,常用于关闭文件描述符、释放锁等资源释放场景。
  • 不仅函数正常返回会执行被defer延迟的函数,函数中任意一个return语句、panic语句均会触发延迟函数。
  • 函数的return语句并不是原子的,实际执行分为设置返回值和ret两步,defer语句实际上执行在返回前,即拥有defer的函数返回过程是:设置返回值->执行defer->ret。所以return语句先把result设置为i值,即1,defer语句中又把result递增为1,所以最终返回2。
    func DeferDemo()(result int){
      i:=1
      defer func(){
          result++
      }()
      return i // 2
    }
    
    func DeferDemo() int {
      i := 1
      defer func() {
          i++
      }()
      return i // 1
    }
    
  • defer 使用场景
    • 常用于关闭文件句柄、数据库连接、停止定时器Ticker及关闭管道等资源清理场景
    • 流程控制:配合wg.Wait()实现等待协程退出
    • 异常处理:与recover()配合可以消除panic。另外,recover只能用于defer函数中。
  • defer行为规则
    • 延迟函数的参数在defer语句出现时就已经确定了。
    • 延迟函数按后进先出(LIFO)的顺序执行,即先出现的defer最后执行
    • 延迟函数可能操作主函数的具名返回值。因为函数return不是原子的。
  • defer分类
    • 堆defer:创建的defer节点存放在堆中,用链表连接起来。缺点是在于频繁的堆内存分配及释放
    • 栈defer:创建的defer节点存放在栈中。缺点是栈空间有限,不能把所有的defer都存储在栈中,所以还需要保留堆defer
    • 开放编码defer:将defer语句插入函数尾部,节省了_defer节点转储的代价。不能使用开放编码defer的场景
      • 编译时禁用了编译器优化,即-gcflags="-N -1"
      • defer出现在循环语句中
      • 单个函数defer出现了8个以上,或者return语句的个数和defer语句的个数乘积超过了15
    • 开放编码defer与堆defer和栈defer最显著的区别是编译器完成了defer语句的预处理,运行时不需要参与预处理defer,只关注执行即可。
  • 开发建议
    • 单个函数如果存在过多的defer,可以考虑拆分函数
    • 单个函数如果存在过多的return语句,那么需要控制defer的使用数量
    • 在循环中使用defer语句需要慎重。
panic
  • panid(v interface{})是一个内置函数,它接受一个任意类型的参数,参数将在程序崩溃时通过另一个内置函数print(args …Type)打印出来。如果程序返回途中任意一个defer函数执行了recover(),那么该参数也是recover()的返回值。
  • panic会递归执行协程中所有的defer,与函数正常退出时的执行顺序一致。
  • panic不会处理其他协程中的defer;
  • 当前协程中的defer处理完成后,触发程序退出。
  • 如果panic在执行过程中(defer函数中)再次发生panic,程序将立即终止当前defer函数的执行,然后继续接下来的panic流程,只是当前defer函数中panic后面的语句就没有机会执行了。
recover
  • recover()函数的返回值就是panic()函数的参数,当程序产生panic时,recover()函数就可用于消除panic,同时返回panic()函数的参数,如果程序没有发送panic,则recover()函数返回nil。
  • panic()函数的参数为nil,仍然是一个有效的panic,此时recover()函数仍然可以捕获panic,但返回值为nil。
  • recover()函数必须且直接位于defer函数中才有效,不能出现在另一个嵌套函数中。下面的函数就无法捕获panic
func RecoverDemo(){
	defer func(){
	    func(){
	        if err:=recover();err!=nil{
	        	fmt.Println("A")
	        }   	
        }()	
    }
    panic("demo")
	fmt.Println("B")
}
  • recover()函数成功处理异常后,无法再次回到本函数发生的panic的位置继续执行
  • recover()函数可以消除本函数产生或收到的panic,上游函数感知不到panic的发生。
  • 当函数中发生panic并用recover()函数恢复后,当前函数仍然会继续返回,对于匿名返回值,函数将返回相同类型的零值,对于具名返回值(返回参数指定变量名 result int),函数将返回当前已经存在的值。

第九章 定时器

  • 一次性定时器(Timer):
    • NewTimer()创建一个新的Timer交给系统协程监控
    • Stop()通知系统协程删除指定的Timer
    • Reset()通知系统协程删除指定的Timer并再添加一个新的Timer
    • Reset()应该作用于已经停止的Timer或已经触发的Timer。如果不按照此约定使用Reset(),则有可能遇到Reset()和Timer()触发后同时执行的情况,此时有可能会收到两个事件,从而对程序造成一些负面影响。
package main
import (
	"fmt"
	"sync"
	"time"
)
func main() {
	fmt.Println("hello1", time.Now())
	tick := time.NewTimer(3 * time.Second) // 设置定时器
	select {
	case <-tick.C: // 定时器到期
		fmt.Println("hello2", time.Now())
	}
	tick.Reset(time.Second * 20) // 重置定时器
	tick.Stop()                  // 停止定时器
	tick.Reset(2 * time.Second)  // 重置定时器
	select {
	case <-tick.C:
		fmt.Println("hello3", time.Now())
	}
	<-time.After(time.Second) // 创建一个定时器,返回一个channel
	fmt.Println("after:", time.Now())
	var wg sync.WaitGroup
	wg.Add(1)
	tick = time.AfterFunc(time.Second, func() { // 异步执行
		fmt.Println("afterFunc:", time.Now())
		wg.Done()
	})
	wg.Wait()
}
  • 周期性定时器(Ticker)
    • 使用time.NewTicker()创建一个定时器
    • 使用Stop()停止一个定时器,只是简单的把Ticker从系统协程中移除,并不会关闭管道,以避免用户协程读取错误。
    • 定时器使用完要释放,否则会产生资源泄漏,一定要显示Stop
    • 简单启动一个永远不停止的定时器,Tick
package main

import (
	"fmt"
	"runtime"
	"time"
)

func main() {
	tick := time.Tick(time.Second)
	for{
		select {
		case <-tick:
			fmt.Println("resource leak!",runtime.NumGoroutine())
		}
	}
}

// 错误使用方式,会造成资源泄漏问题
func errUser() {
  for{
    select {
    case <-time.Tick(time.Second):
      fmt.Println("resource leak!",runtime.NumGoroutine())
    }
  }
}
  • NewTimer()和NewTicker()都会在底层创建一个runtimeTimer,runtime包负责管理runtimeTimer,保证定时器按照约定的时间触发。
  • 创建定时器的协程不负责计时,而是把任务交给系统协程,系统协程统一处理所有的定时器。
  • Ticker资源泄漏场景
    • 创建了Ticker,忘记在使用结束后“Stop”
    • 从别处复制代码时未复制Stop
    • 开源或第三方库中发生泄漏
    • 解决 defer ticker.Stop()
  • 资源泄漏的现象
    • CPU使用率持续升高,不会随时间下降
    • CPU使用率缓慢升高,不会急剧升高,比较隐蔽

第10章 语法糖

  • 短变量 :=
    • 在同一作用域内
      • 当":="左侧存在新变量时,已声明的变量会被重新声明,不会有其他额外副作用
      • 当":="左侧没有新变量是不允许的
    • 短变量只能用在函数中,不能用来声明和初始化全局变量
    • 在不同作用域中,同名变量重新声明会产生新的同名变量
  • 可变参数
    • 可变参数必须要位于函数列表尾部
    • 可变参数是被当做切片来处理的
    • 函数调用时,可变参数可以不填
    • 函数调用时,可变参数可以填入切片。切片传入不会生成新的切片,也就是说函数内部使用的切片与传入的切片共享相同的存储空间。如果函数内部修改了切片,则可能影响外部调用的函数。

gvm版本管理

第十二章 GO语言依赖管理

  • go module解决的主要问题
    • 准确地记录项目依赖
    • 可重复的构建
  • module版本号必须遵循语义化规范,版本号必须使用v(major).(minor).(patch)格式,如v1.0.0或v1.5.0-rc.1
    • major:当发生不兼容的改动时才可以增加该版本
    • minor:当有新增特性时才可以增加该版本
    • patch:当有bug修复时才可以增加该版本
  • 语义化版本规范的好处是,用户通过版本号就能了解版本信息。
  • go.mod记录版本依赖
    • module:声明module的名称
    • require:声明依赖及其版本号
    • replace:替换require中声明的依赖,使用另外的依赖及其版本号
      • replace仅在当前module为main module时有效,如果其他项目A引用module B,那么A编译时,B项目的replace将被忽略
      • replace指令中“=>”前面的包及其版本号必须出现在require中才有效,否则指令无效,也会被忽略
    • exclude:禁用指定的依赖。限制和replace一样
  • replace使用场景
    • 替换无法下载的包。如google不能下载,但是github上有,可以替换为github的包,但是我们在import时,仍然需要引用google的包,因为包里的go.mod的module名称为google,这个决定了import的引用路径
    • 调试依赖包:替换远程依赖为本地,调试
    • 使用fork仓库。当前代码有bug,fork之后引用。一旦开源版本变得可用,则需要尽快切换到开源版本。
    • 禁止被依赖。k8s仓库里的go.mod,存在v0.0.0,不存在的版本,用replace来指定真正的代码。这是为了不让k8s作为一个整体被直接使用,其他项目需要时可以引用相关子module。
  • 查看当前引用的实际包:go list -m all
  • 初始化module:go mod init github.com/xxx/hello
  • 下载依赖包:go get总是获取最新的依赖包
  • indirect指令
    • 直接依赖未启用go module的模块
    • 直接依赖go.mod文件中确实的部分依赖。
  • 依赖来源查看 go mod why -m
  • 使用版本大于1时需要在module里加上v2或v3,如module github.com/robfig/cron/v3。否则引用的时候会出现 +incompatible
  • 伪版本:对于最新提交代码没有tag,则称这个版本为伪版本,如v0.0.0-20201023121146-jjsdhsjdsjdq
  • 依赖包存储:在GOPATH模式下,依赖包存储在 G O P A T H / s r c 下 , 该 目 录 下 只 保 持 特 定 依 赖 包 的 一 个 版 本 。 而 在 G O M O D U L E 模 式 下 , 依 赖 包 存 储 在 GOPATH/src下,该目录下只保持特定依赖包的一个版本。而在GOMODULE模式下,依赖包存储在 GOPATH/srcGOMODULEGOPATH/pkg/mod下,该目录下可以存储特定依赖包的多个版本。
    • $GOPATH/pkg/mod目录下有一个cache目录,它用来存储依赖包的缓存,go命令每次下载新的依赖包都会在该cache目录中保存一份。
    • 在GOMODULE中,依赖包的目录包含了版本号,每个版本占用一个目录,依赖包的特定版本目录中只包含依赖包文件,不包含.git目录。
    • 包名大小写问题:大小会用 !+相应小写字母,如"BurntSushi"变为"!burnt!sushi"。
  • go.sum记录module的Hash值:在构建时,如果本地依赖包的Hash值与go.sum文件中记录的内容不一致,则会拒绝构建。
  • GOPROXY模块代理
  • 下载步骤:-x打印下载细节
% go mod download -x -json github.com/google/uuid@latest
# get https://goproxy.cn/github.com/google/uuid/@v/list
# get https://goproxy.cn/github.com/google/uuid/@v/list: 200 OK (0.521s)
# get https://goproxy.cn/github.com/google/uuid/@v/v1.3.0.info
# get https://goproxy.cn/github.com/google/uuid/@v/v1.3.0.info: 200 OK (0.021s)
# get https://goproxy.cn/github.com/google/uuid/@v/v1.3.0.mod
# get https://goproxy.cn/github.com/google/uuid/@v/v1.3.0.mod: 200 OK (0.023s)
# get https://sum.golang.google.cn/lookup/github.com/google/uuid@v1.3.0
# get https://sum.golang.google.cn/lookup/github.com/google/uuid@v1.3.0: 200 OK (0.219s)
# get https://sum.golang.google.cn/tile/8/0/x026/423
# get https://sum.golang.google.cn/tile/8/2/000.p/105
# get https://sum.golang.google.cn/tile/8/1/105.p/229
# get https://sum.golang.google.cn/tile/8/0/x027/109.p/18
# get https://sum.golang.google.cn/tile/8/1/103
# get https://sum.golang.google.cn/tile/8/0/x026/423: 200 OK (0.050s)
# get https://sum.golang.google.cn/tile/8/2/000.p/105: 200 OK (0.050s)
# get https://sum.golang.google.cn/tile/8/0/x027/109.p/18: 200 OK (0.051s)
# get https://sum.golang.google.cn/tile/8/1/103: 200 OK (0.052s)
# get https://sum.golang.google.cn/tile/8/1/105.p/229: 200 OK (0.053s)
# get https://sum.golang.google.cn/tile/8/0/x023/417
# get https://sum.golang.google.cn/tile/8/0/x023/417: 200 OK (0.046s)
# get https://goproxy.cn/github.com/google/uuid/@v/v1.3.0.zip
# get https://goproxy.cn/github.com/google/uuid/@v/v1.3.0.zip: 200 OK (0.043s)
{
        "Path": "github.com/google/uuid",
        "Version": "v1.3.0",
        "Info": "/Users/junmo/go/pkg/mod/cache/download/github.com/google/uuid/@v/v1.3.0.info",
        "GoMod": "/Users/junmo/go/pkg/mod/cache/download/github.com/google/uuid/@v/v1.3.0.mod",
        "Zip": "/Users/junmo/go/pkg/mod/cache/download/github.com/google/uuid/@v/v1.3.0.zip",
        "Dir": "/Users/junmo/go/pkg/mod/github.com/google/uuid@v1.3.0",
        "Sum": "h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=",
        "GoModSum": "h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo="
}
  • GOSUMDB:校验和数据库通过存储模块版本的Hash值,相当于给该模块提供了公证服务,任何人来查都能得到相同的Hash值。
  • 校验流程
    • 一是模块被下载后,go命令会对下载的模块做Hash运算,然后与校验和数据库中的数据进行对比,以此来确保下载模块是合法的。
    • 二是模块Hash值被添加到go.sum文件之前,go命令对缓存在本地的模块做Hash运算,然后与校验和数据库中的数据进行对比,以此来确保本地的模块没有被篡改。
  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值