Golang 进阶训练营 您所在的位置:网站首页 golang进阶训练营课件 Golang 进阶训练营

Golang 进阶训练营

2024-05-18 23:47| 来源: 网络整理| 查看: 265

一、Golang 的 slice、map、channel 1.1 slice vs array a := make([]int, 100) //切片 b := [100]int{} //数组

array需指明长度,长度为常量且不可改变 array长度为其类型中的组成部分(给参数为长度100的数组的方法传长度为101的会报错) array在作为函数参数时会产生copy golang所有函数参数都是值传递

array扩容:cap缓存行锁)。缓存行的状态,由硬件同步。MESI 为 Modified, Exclusive, Shared, Invalid 缩写。 Invalid:无效。初始化状态,或者内存不可用(被其他CPU修改,需要更新缓存) Exclusive:独占。仅当前CPU缓存了该内存。 Shared:共享。多个CPU缓存了该内存。 Modified:已修改、未写回。需要其他CPU的缓存失效。某个CPU更新了缓存,但是还没写回内存,其他CPU缓存的该内存信息更新为 Invalid。 状态转移图 自旋锁(Spin Lock) Linux 内核中常见,适合等待时间比较小的场景 Go 1.14 版本之前,没有实现抢占式调度,必须某个 goroutine 交出控制权,因此自旋锁会导致死锁。如下图:A等待B释放锁,但是执行了GC,然后B被挂起,runtime需要等待A挂起,但是A在执行自旋锁,就发生了死锁。需要在自旋锁内部调用一次 runtime.GoSched 来交出 CPU 控制权 自旋锁死锁样例 Go's Mutex 效率优先,兼顾公平。

Mutex 有自己的一个等待队列,有自己的状态 state(正常模式和饥饿模式)。正常模式保证效率,饥饿模式保证公平。state是一个共用字段,由锁标志位,唤醒标志位,饥饿标志位和阻塞的goroutine个数组成。

Go's Mutex State 字段组成(mutexLocked mutexWoken mutexStarving 位为 1 分别表示锁占用、锁唤醒、饥饿模式、mutexWaiterShift 表示偏移量,默认为3,state>>=mutexWaiterShift,state的值就表示当前阻塞等待锁的goroutine个数。最多可以阻塞2^29个goroutine) 正常模式:goroutine等待队列先进先出;新来的goroutine先去抢占锁,失败了再进入等待队列;如果发现某个抢到锁的 goroutine 等待时长 > 1ms,则切换到饥饿模式。 饥饿模式:严格排队,队首接盘;牺牲效率,保证Pct99;适时回归正常模式,保证效率。如果某个goroutine加锁成功后,如果发现这个goroutine位于队尾,或者等待时间小于1ms,那么就切换回正常模式。 提高效率的点:1. 新来的先去抢锁,减少了调度开销。2. 充分利用缓存,提高执行效率。 加锁流程 state位定义 自旋流程 加锁流程 解锁流程1(Slow) 解锁流程2 总结 Go's Once type Once struct { done uint32 m Mutex } func (o *Once) Do(f func()) { if atomic.LoadUint32(&o.done) == 0 { // Outlined slow-path to allow inlining of the fast-path. o.doSlow(f) } } func (o *Once) doSlow(f func()) { o.m.Lock() defer o.m.Unlock() if o.done == 0 { defer atomic.StoreUint32(&o.done, 1) f() } }

源码很简单,如上:

问题:1. 为什么 Do 里面用 atomic,doSlow 里面用 o.done==0?2. 为什么 doSlow 里面用 atomic 来设置 done?3. 为什么 doSlow 里面用 defer 设置值?可否直接设置? 解答:1. Do 里面没有加锁,如果直接 o.done == 0 可能观测到非常规值,使用 atomic 保证操作有序。而 doSlow 里面已经是锁内部,不可能存在其他的 goroutine 修改值,因此可以直接观测。2. 由于可能存在其他 goroutine 在 Do 内观测 done 的值,因此需要 atomic 设置值来保证有序性。3. 不可以直接设置,如果直接设置值再执行f,那么可能 f 还没执行,别的 goroutine 已经观测到 done 为 1,直接 Do 中返回,但是由于 f 的初始化函数还没完成,从而导致 panic(空指针等)。因此在 f 未执行完的过程中,所有执行 once.Do 的 goroutine 都被阻塞在 doSlow 的 Lock 阶段,等待 f 执行完成才可以返回。 异常:假设 Do 使用 o.done == 0 来观测值,读取的同时当 atomic 正在修改值时,读取到的值可能是异常值;假设使用 o.done=1 来设定值,执行的同时当其他 goroutine 在 Do 中读取 o.done 时,可能看到异常值。 总结:这里的两个 atomic 是为了保证多个 goroutine 观测和设定同时发生的有序性。而锁操作的临界区内可以直接观测变量值。

Go's WaitGroup 下文 第八部分。巧妙地避免了锁的使用。

锁的进化总结 单核:关中断->CAS指令 多核:LOCK内存总线->MESI协议 自旋锁:效率和公平不够好 Go Mutex:效率优先,兼顾公平。

思考探索

思考探索 延伸阅读 踩坑记:Go服务灵异panic:https://mp.weixin.qq.com/s/wmdmYDenmOY2un6ymlO6SA Go: 关于锁的1234: https://mp.weixin.qq.com/s/TRE8_0wLYv22NHXpMmvKqw Go中锁的那些姿势,估计你不知道: https://studygolang.com/articles/26030 Race Detector: https://blog.golang.org/race-detector sync: mutex.TryLock:https://github.com/golang/go/issues/6123 Recursive locking in Go:https://stackoverflow.com/questions/14670979/recursive-locking-in-go 四、Golang 并发数据结构和算法实践 引言

scalable:当计算资源更多时,性能会有提升

Golang数据结构并发测试(-x代表使用的CPU数) 4.1 并发安全问题 Data Race 原因:多个 goroutine 同时接触一个变量,行为不可预知。 认定条件:两个及以上 goroutine 一写多读:atomic 多写一读:Lock + atomic 多写多读:Lock + atomic 4.2 实践一:有序链表并行化

定义插入删除的多个步骤,举例不同场景,考虑并发情况下是否满足,如何调整步骤。

4.3 实践二:skiplist并行化

从 4.2 延伸过来,每一层的 list 都可以使用 4.2 的实现。

4.4 总结 五、Golang 并发数据结构和算法实践 5.1 调度循环的建立 GM 模型和 GMP 模型 调度循环的建立 调度循环的建立 5.2 协作与抢占 调度器的坑 go 运行一个死循环在 go1.13 版本会被卡死,go1.14 引入基于信号的抢占,从而不会被卡死。

Go 的调度方式 协作式调度:依靠被调度放主动弃权 抢占式调度:依靠调度器强制将被调度方中断 s

Go的调度方式 基于信号的抢占 小结 协作与抢占小结 六、垃圾回收和Golang内存管理 6.1 GC基本理论 自动内存管理:Reference Counting 引用计数法;Tracing GC Tracing GC: 目标:找出活的对象,剩下的就是垃圾。 两部分:GC root 和 GC heap。 风格:Copying GC 和 Mark-Sweep GC。 并发:Concurrent GC(GC过程用户代码不需要停下来) 和 Parallel GC(GC过程中用户代码暂停) 6.2 Go内存管理 Go GC简史 历史 Go 1.10 版本以前采用的方式是线性内存。所有申请的内存以Page方式分割,每个Span管理一个或者多个Page。Golang垃圾回收的时候,会通过判断指针的地址来判断对象是否在堆中。之后弃用原因:1. 在C,Go混用时,分配的内存地址会发生冲突,导致堆得初始化和扩容失败;2. 没有被预留的大块内存可能会被分配给 C 语言,导致扩容后的堆不连续。 Go 1.10 之后采用稀疏内存管理。 分配 关键词 Tcmalloc 风格分配器:thread cache 分配器、三级内存管理 按照不同大小分类:Tiny(8), Small(16~32K), Huge(>32K) mcache 来减轻锁的开销(每个处理器 P 维护一段内存) 内外部碎片 Object 定位 Bitmap 标记 总览 Go在程序启动时,会向操作系统申请一大块内存,之后自行管理。 Go内存管理的基本单元是mspan,它由若干个页组成,每种mspan可以分配特定大小的object。 mcache, mcentral, mheap是Go内存管理的三大组件,层层递进。mcache管理线程在本地缓存的mspan;mcentral管理全局的mspan供所有线程使用;mheap管理Go的所有动态分配内存。 极小对象(小于16字节)会分配在一个object中,以节省资源,使用tiny分配器分配内存;一般小对象(16字节到32768字节)通过mspan分配内存,根据对象大小选择对应的额mspan;大对象(大于32768字节)则直接由mheap分配内存,并记录 spanClass=0。 微对象 (0, 16B) — 先使用微型分配器,再依次尝试线程缓存、中心缓存和堆分配内存;(注:对于(0, 16B) 的指针对象,直接归类为小对象。微型分配器不分配指针类型对象) 小对象 [16B, 32KB] — 依次尝试使用线程缓存、中心缓存和堆分配内存; 大对象 (32KB, +∞) — 直接在堆上分配内存; 详解 与TCMalloc非常类似.Golang内存分配由mspan,mcache,mcentral,mheap组成。可以说基本对应了TCMalloc中的Span,Pre-Thread,Central Free List,以及Page Heap。分配逻辑也很像TCMalloc中依次向前端,中端,后端请求内存。 Golang内存管理组件 在Golang的程序中,每个处理器都会分配一个线程缓存 mcache 用于处理微对象以及小对象的内存分配,mcache管理的单位就是mspan。 mcache会被绑定在并发模型中的 P 上.也就是说每一个 P(处理器) 都会有一个mcache,用于给对应的协程的对象分配内存; mspan 是真正的内存管理单元,其根据定义的 67 种 spanClass 来管理内存(从8bytes到32768bytes==32KB),不同大小的对象,向上取整到对应的 spanClass 中管理。type spanClass uint8,其实 spanClass 的载体就是一个8位的数据,他的前七位用于存储当前 mspan 属于68种的哪一种,最后一位代表当前 mspan(当前对象) 是否存储了指针,这个非常重要,因为是否存在指针意味着是否需要在垃圾回收的时候进行扫描; mcache中的缓存对象数组 alloc [numSpanClasses]*mspan 一共有(67) * 2个,其中*2是将spanClass分成了有指针和没有指针两种,方便与垃圾回收; 如果mcache中缓存的对象数量不够了,也就是alloc数组中缓存的对象不足,会向mheap持有的 numSpanClasses*2 个mcentral获取新的内存单元(这里的 *2 也是mcache中的 *2,对应了无指针和有指针) 每个 mcentral 维护一种 mspan,而 mspan 的种类会导致其分割的 object 大小不同。mcentral 被所有的工作线程共同享有,存在多个Goroutine竞争的情况,因此会消耗锁资源; mcache向 mcentral 申请空间的方法 mheap_.central[spc].mcentral.cacheSpan() mcentral中心缓存是属于全局结构mheap的,mheap就是用来管理Golang所申请的所有内存,如果mheap的内存也不够,则会向操作系统申请内存 heapArena用于管理真实的内存 回收 Go GC STW Mark Concurrent mark Mark-Sweep 三色法 黑灰白:黑 标活且内容全部扫描完;灰 标活且内容未扫描完;白 未扫描到 Non-generational 何时触发回收 GOGC threshold。阈值,假设设定 export GOGC=100,那么每次GC结束后,剩余活对象的内存占用空间的两倍(1+$(GOGC)%)作为下次GC的阈值,达到或者超过,则启动GC runtime.GC() runtime.forcegcperiod(2min) 3.编程者指南 六、性能 pprof 工具 pprof工具 使用方式:go tool pprof -http=:8080。输入网页查看:http://localhost:6060/debug/pprof 网页后缀 /profile 查看 CPU 采样信息 网页后缀 /heap 查看 堆占用 采样信息 七、缓存相关 1. local cache local cache 对比 local 选型

大key问题:

考虑拆分成多个key来存储 比如用hash取余/位掩码的方式决定放在哪个key中 对于需要全量数据的场景,会增加一定数据请求和组装的成本 考虑拆分冷热数据 redis中只存储热数据,对于命中率不高的冷数据,使用其他异构数据库 如粉丝列表场景使用zset,只缓存前10页数据,后续走db/hbase

推荐阅读: 《redis redlock 是否可靠?》

八、内存对齐

依次看:Golang 是否有必要做内存对齐?、Golang 内存对齐 简单总结:对齐是因为CPU不是支持任意字节获取内存的,而是一块一块获取,所以对齐的好处是防止CPU需要两次操作才能读取数据,从而降低效率。如果未对齐,则通过padding来补齐未对齐部分。x86 是4字节对齐,现在的64位系统通常是8字节对齐(比如 int64 刚好够,int8 int32 单独的就需要补齐,同时出现的话可以将 int32 补在 int8 后面,形成1字节int8,3字节padding,4字节int32的8字节对齐格式)。 Go Struct 偏移量还会内存分配的知识点:比如 SpanClass 的选定也会影响到数据分配的偏移量。(32, 48] 字节的 struct 会使用 48 字节的 Span。因此即使是顺序分配,也是 48 字节的 offset 间隔。

内存对齐使用举例:Go WaitGroup state函数会判断编译器是否是8字节对齐来决定 waiter 计数器、counter 计数器以及信号量的排列顺序。 type WaitGroup struct { noCopy noCopy // 辅助vet工具检查是否通过copy赋值WaitGroup state1 [3]uint32 // 数组,组成 waiter 计数器、counter 计数器以及信号量 // counter 代表目前尚未完成的个数。WaitGroup.Add(n) 将会导致 counter += n, 而 WaitGroup.Done() 将导致 counter--。 // waiter 代表目前已调用 WaitGroup.Wait 的 goroutine 的个数。 // sema 对应于 golang 中 runtime 内部的信号量的实现。 // WaitGroup 中会用到 sema 的两个相关函数,runtime_Semacquire 和 runtime_Semrelease。 // runtime_Semacquire 表示增加一个信号量,并挂起 当前 goroutine。 // runtime_Semrelease 表示减少一个信号量,并唤醒 sema 上其中一个正在等待的 goroutine } func (wg *WaitGroup) state() (statep *uint64, semap *uint32) { if uintptr(unsafe.Pointer(&wg.state1))%8 == 0 { // 8字节对齐 return (*uint64)(unsafe.Pointer(&wg.state1)), &wg.state1[2] } else { // 4字节对齐 return (*uint64)(unsafe.Pointer(&wg.state1[1])), &wg.state1[0] } } Go WaitGroup state 字段含义(如果是8字节对齐,也就是满足第一个 if,那么使用前两个 uint32 组成一个 uint64 来返回,根据移位操作确认 waiter 和 counter 值,如果是4字节对齐,那么 if 条件判断失败,使用后两个 uint32 组成一个 uint64 返回。当然,这里 4 字节对齐的时候,也可能 state1 刚好处于 8 字节对齐的位置,那么会按照 8 字节对齐处理,这主要取决于内存分配时的具体情况。) Add 操作 使用规范:默认使用者传入的 delta 为正数,使用者不应该传入 Add 函数一个负数 func (wg *WaitGroup) Add(delta int) { statep, semap := wg.state() // delta左移32位,将delta原子添加到高位计数器上 state := atomic.AddUint64(statep, uint64(delta) 32) // 右移32位,获取高32位计数器,因为 v 存在被 delta 操作,所以可能为负数。 w := uint32(state) // 高位截断,获取低32位Waiter计数器,w 只可能在 Wait 函数中被 atomic +1,不可能为负数 if v < 0 { // 计数器不能小于0(使用者非预估:调用 Add 加了负数) panic("sync: negative WaitGroup counter") } // 计数器数据不一致,计数器和delta一样的情况下,waiter 不是 0(使用者非预估:调用 Add 且调用 Wait 时,又调用 Add,导致并发问题) if w != 0 && delta > 0 && v == int32(delta) { panic("sync: WaitGroup misuse: Add called concurrently with Wait") } if v > 0 || w == 0 { // 正确add,return return } // 走到这里,说明 v==0 && w>0 (v0被返回,w==0被返回) if *statep != state { // state数值发生不一致(使用者非预估:v==0时,w发生变化,说明在Add调用过程中,Wait或者Add被非预估调用) panic("sync: WaitGroup misuse: Add called concurrently with Wait") } *statep = 0 // 直接至零,表明 v==0 且 w==0,同时唤醒所有的 wait 状态的 goroutine,只有最后一个 Done 的 goroutine 会这么做 for ; w != 0; w-- { // 依次唤醒 runtime_Semrelease(semap, false, 0) // 释放信号量 } } Done 操作 预期内只有调用 Done 时,才会调用 Add 并传入负值 // Done decrements the WaitGroup counter by one. func (wg *WaitGroup) Done() { wg.Add(-1) } Wait 操作 通过自旋和乐观锁,保证计数器正确被更新 func (wg *WaitGroup) Wait() { statep, semap := wg.state() for { // 自旋循环 state := atomic.LoadUint64(statep) v := int32(state >> 32) // 右移32位,获取高32位计数器 w := uint32(state) // 高位截断,获取低32位Waiter计数器 if v == 0 { // 计数器为0,不需要继续等待 return } // 如果计数器不为0,调用wait方法的goroutine需要等待,等待计数器+1,并发调用安全 // 如果 state 发生了变化,则自旋,并重新观测 counter 并更新 waiter if atomic.CompareAndSwapUint64(statep, state, state+1) { runtime_Semacquire(semap) // 获取信号量 // 被唤醒时,肯定是最后一个 goroutine Done,并依次唤醒所有的在 Wait 的 goroutine,此过程中不期望 state 发生变化(即存在并发的 Done 操作或者 Add 操作或者 Wait 操作) if *statep != 0 { // 在wait返回前,WaitGroup被重用了(不期望的事情发生了) panic("sync: WaitGroup is reused before previous Wait has returned") } return } } } 思考: 把 waiter 和 counter 合并成一个变量: 为了避免使用锁,直接利用 atomic 操作,保证两者的改动是同时的。比如Wait时通过乐观锁进行操作,如果同时Done或者Wait被调用,那么会自旋重新观测v,如果v==0则直接返回,否则 waiter 计数+1且进入挂起等待。 冲突考虑(这里的 Add 表示 Add正值,Add负值的情况视为Done):Add 和 Wait 不能并发,Add 和 Done 可以并发,Wait 和 Done 可以并发。 使用规范: Add 不能和 Wait 并发调用,必须由一个 goroutine 调用这两个函数。 不应该 Add 一个负值。Done 即为 Add(-1)。 不能在 Add 之前调用 Done。


【本文地址】

公司简介

联系我们

今日新闻

    推荐新闻

    专题文章
      CopyRight 2018-2019 实验室设备网 版权所有