go笔记

1、Slice和Map的本质

  • 切片(Slice)是一个拥有相同类型元素的可变长度的序列。它是基于数组类型做的一层封装。它非常灵活,支持自动扩容。

img

判断切片是否为空用len(),不能用nil

sort

//切片才能用sort
var a1 = [...]int{3, 7, 8, 9, 1} //属于数组
a2:=a1[:] //使用后遍切片
sort.Ints(a1[:]) //才可以使用sort
//sort.Ints(a1)
fmt.Println(a,len(a))
fmt.Printf("%#+v",a1)
fmt.Printf("%#+v",a2)

拷贝

  • 浅拷贝只复制指向某个对象的指针,而不复制对象本身,新旧对象还是共享同一块内存。但深拷贝会另外创造一个一模一样的对象,新对象跟原对象不共享内存,修改新对象不会改到原对象。使用copy才是深拷贝

  • 首先go的变量要不在栈上要不在堆上,栈上的变量会在函数销毁的时候就释放了,堆上的就要靠gc算法来了,我们一般说从栈逃逸到堆上或者一开始直接就在堆上的变量内存叫做内存逃逸

从切片中删除元素

Go语言中并没有删除切片元素的专用方法,我们可以使用切片本身的特性来删除元素。 代码如下:

func main() {
	// 从切片中删除元素
	a := []int{30, 31, 32, 33, 34, 35, 36, 37}
	// 要删除索引为2的元素
	a = append(a[:2], a[3:]...)
	fmt.Println(a) //[30 31 33 34 35 36 37]
}

map

map,键值对,使用hash表结合数组和链表优点

  • 数组:创建需要连续的内存空间数据类型必须一致,可以索引下表查询获取元素O(1),插入需要遍历每个元素O(n)

  • 链表:不需要连续的内存空间,数据类型可以不一致必须包含一个指向下一个数据元素的内存地址指针,查询要遍历O(n),插入删除O(1)

  • hash:使用余数法对key取余数作为数组的索引,落到数组的一个槽中(每个槽并不存放k-v数据,它们都是指针),然后使用链表将k-v连接起来。查询的时候,获取槽位后,hash余数相同时使用,链地址法接在同个链表,遍历链表来查询

  • 哈希函数会将传入的key值进行哈希运算,得到一个唯一的值。go语言把生成的哈希值一分为二,比如一个key经过哈希函数,生成的哈希值为:8423452987653321,go语言会这它拆分为84234529,和87653321。那么,前半部分就叫做高位哈希值,后半部分就叫做低位哈希值。

    高位哈希值(bmap):是用来确定当前的bucket(桶)有没有所存储的数据的。

    低位哈希值(hmap):是用来确定,当前的数据存在了哪个bucket(桶)1.1 根据key计算hash值;
    1.2 hash值低位hmap.B取模来确定bucket的位置;
    1.3 取hash值高位在bhmap的tophash数组中查询;
    1.4 当前key的hash值与tophash[i]中存储的hash值相等的话,就去data里面找key;

    如果不相等,是因为触发了hash冲突,,但是key不相同,通过overflow指针就去链表中下一个溢出bucket中查找,直到查找到链表的末尾

    1.5 当前的bucket没找到,就去overflow中的bucket中去找。
    需要主要的是,在发生数据搬迁过程中,查找优先从oldbucket中进行;另外,找不到,会返回对应类型的零值,而不是nil

3、chan

  • 通道像一个传送带或者队列,总是遵循先入先出

qcount 当前的队列,剩余元素个数
dataqsiz 环形队列可以存放的元素个数,也就是环形队列的长度
buf 指针,指向环形队列 //循环队列
elemsize 指的的队列中每个元素的大小
closed 具体标识关闭的状态
elemtype 见名知意,元素的类型
sendx 发送队列的下标,向队列中写入数据的时候,存放在队列中的位置
recvx 接受队列的下标,从队列的 这个位置开始读取数据
recvq 协程队列,等待读取消息的协程队列
sendq 协程队列,等待发送消息的协程队列
lock 互斥锁,在 chan 中,不可以并发的读写数据

写 入chan

第一张图说明白向 chan 写入数据的流程

img

向通道中写入数据,

根据图示可以看出向通道中写入数据分为 3 种情况:

写入数据的时候,若recvq 队列为空,且循环队列有空位,那么就直接将数据写入到 循环队列的队尾 即可
若recvq 队列为空,且循环队列无空位,则将当前的协程放到sendq等待队列中进行阻塞,等待被唤醒,当被唤醒的时候,需要写入的数据,已经被读取出来,且已经完成了写入操作
若recvq 队列为不为空,那么可以说明循环队列中没有数据,或者循环队列是空的,即没有缓冲区(向无缓冲的通道写入数据),此时,直接将recvq等待队列中取出一个G,写入数据,唤醒G,完成写入操作

读取chan

img

向通道中读取数据,我们会涉及sendq 、 recvq队列,和循环队列的资源问题

根据图示可以看出向通道中读取数据分为 4 种情况:

若sendq为空,且循环队列无元素的时候,那就将当前的协程加入recvq等待队列,把recvq等待队列对头的一个协程取出来,唤醒,读取数据
若sendq为空,且循环队列有元素的时候,直接读取循环队列中的数据即可
若sendq有数据,且循环队列有元素的时候,直接读取循环队列中的数据即可,且把sendq队列取一个G放到循环队列中,进行补充
若sendq有数据,且循环队列无元素的时候,则从sendq取出一个G,并且唤醒他,进行数据读取操作
上面说了通道的创建,读写,那么通道咋关闭?

通道的关闭,我们在应用的时候直接 close 就搞定了,那么对应close的时候,底层的队列都是做了啥呢?

若关闭了当前的通道,那么系统会把recvq 读取数据的等待队列里面的所有协程,全部唤醒,这里面的每一个G 写入的数据 默认就写个 nil,因为通道关闭了,从关闭的通道里面读取数据,读到的是nil

4、RPC

在本地调用中,函数主体通过函数指针函数指定,然后调用 add 函数,编译器通过函数指针函数自动确定 add 函数在内存中的位置。但是在 RPC 中,调用不能通过函数指针完成,因为它们的内存地址可能完全不同。因此,调用方和被调用方都需要维护一个{ function <-> ID }映射表,以确保调用正确的函数,并且基于RESTful API通常是基于HTTP协议,传输数据采用JSON等文本协议,相较于RPC 直接使用TCP协议,传输数据多采用二进制协议来说,RPC通常相比RESTful API性能会更好。

5、GRPC

  1. https://www.liwenzhou.com/posts/Go/gRPC/#autoid-0-3-4

    gRPC帮你解决了不同语言及环境间通信的复杂性。使用protocol buffers还能获得其他好处,包括高效的序列化,简单的IDL以及容易进行接口更新。总之一句话,使用gRPC能让我们更容易编写跨语言的分布式代码。

服务方法

  • 普通 rpc,客户端向服务器发送一个请求,然后得到一个响应,就像普通的函数调用一样。
  rpc SayHello(HelloRequest) returns (HelloResponse);
  • 服务器流式 rpc,其中客户端向服务器发送请求,并获得一个流来读取一系列消息。客户端从返回的流中读取,直到没有更多的消息。gRPC 保证在单个 RPC 调用中的消息是有序的。
  rpc LotsOfReplies(HelloRequest) returns (stream HelloResponse);
  • 客户端流式 rpc,其中客户端写入一系列消息并将其发送到服务器,同样使用提供的流。一旦客户端完成了消息的写入,它就等待服务器读取消息并返回响应。同样,gRPC 保证在单个 RPC 调用中对消息进行排序。
  rpc LotsOfGreetings(stream HelloRequest) returns (HelloResponse);
  • 双向流式 rpc,其中双方使用读写流发送一系列消息。这两个流独立运行,因此客户端和服务器可以按照自己喜欢的顺序读写: 例如,服务器可以等待接收所有客户端消息后再写响应,或者可以交替读取消息然后写入消息,或者其他读写组合。每个流中的消息是有序的。

6、 context 结构原理

4.1 用途

Context(上下文)是 Golang 应用开发常用的并发控制技术 ,它可以控制一组呈树状结构的 goroutine,每个 goroutine 拥有相同的上下文。Context 是并发安全的,主要是用于控制多个协程之间的协作、取消操作。

img

4.2 数据结构

Context 只定义了接口,凡是实现该接口的类都可称为是一种 context。

  type Context interface {   
      Deadline() (deadline time.Time, ok bool)   
      Done() <-chan struct{}   
      Err() error   
      Value(key interface{}) interface{}
  }

复制代码

  • 「Deadline」 方法:可以获取设置的截止时间,返回值 deadline 是截止时间,到了这个时间,Context 会自动发起取消请求,返回值 ok 表示是否设置了截止时间。
  • 「Done」 方法:返回一个只读的 channel ,类型为 struct{}。如果这个 chan 可以读取,说明已经发出了取消信号,可以做清理操作,然后退出协程,释放资源。
  • 「Err」 方法:返回 Context 被取消的原因。
  • 「Value」 方法:获取 Context 上绑定的值,是一个键值对,通过 key 来获取对应的值。

7、内存

内存与堆内存的区别

栈内存一般由操作系统分配与释放;堆内存一般由程序自身申请与释放
栈内存一般存放函数参数、函数返回值、局部变量、函数调用时的临时上下文等;堆内存一般存放全局变量
栈内存比堆内存访问速度更快
每个线程分配一个栈内存;每个进程分配一个堆内存
栈内存创建时,内存大小是固定的,越界则会发生stack overflow错误;堆内存创建时,内存大小不固定,可随程序运行增加或减少
栈内存是由高地址向低地址增长;堆内存是由低地址向高地址增长

Golang内存管理

基础信息
Golang是自己管理内存,不依赖操作系统,即向操作系统申请一块较大内存,然后自己决定将变量分配到栈空间或对空间
分配选择:基本同上面的分配原则,但对于函数的引用参数会有一些特殊。如果编译器无法证明函数返回之后变量是否仍然被引用,此时就必须在堆空间分配该变量,随后采用垃圾回收机制管理,而从避免指针悬空。此外,如果局部变量过大,也会选择分配在堆空间
总结:最终的分配空间在于编译器的选择,编译器分析变量的生存周期的过程就叫做逃逸分析

栈内存

栈内存的分配与释放全权由操作系统决定,开发者无法控制。一般栈内存会自动创建,函数返回的时候内存会被自动释放。栈内存的分配与释放速度较快

堆内存

对存有由于不确定大小,因此代价就是分配速度较慢且会形成内存碎片。堆内存不能自动被编译器释放,只能通过垃圾回收器才能释放

内存逃逸

把函数内的局部变量通过指针形式 返回
发送指针或带有指针的值到channel中
在切片中存储指针或带指针的值
Slice的底层数组被重新分配。比如使用append向容量已满的Slice追加元素,会重新为Slice分配底层数组
在interface类型上调用方法。因为interface类型调用方法都是动态调度,只有在运行的时候才能知道真正的实现

优化

尽可能避免内存逃逸,因为栈内存的效率远高于堆内存
无法避免的逃逸现象,对于频繁的内存申请操作,可以试着重用内存。比如通过sync.Pool

8、协程和线程和进程的区别?

  • 进程:进程是具有一定独立功能的程序,进程是系统资源分配和调度的最小单位。 每个进程都有自己的独立内存空间,不同进程通过进程间通信来通信。由于进程比较重量,占据独立的内存,所以上下文进程间的切换开销(栈、寄存器、虚拟内存、文件句柄等)比较大,但相对比较稳定安全。
  • 进程间通信:管道、消息队列、共享内存、信号量、socket
  • 线程:线程是进程的一个实体,线程是内核态,而且是 CPU 调度和分派的基本单位,它是比进程更小的能独立运行的基本单位。线程间通信主要通过共享内存,上下文切换很快,资源开销较少,但相比进程不够稳定容易丢失数据。
  • 协程:协程是一种用户态的轻量级线程,协程的调度完全是由用户来控制的。协程拥有自己的寄存器上下文和栈。 协程调度切换时,将寄存器上下文和栈保存到其他地方,在切回来的时候,恢复先前保存的寄存器上下文和栈,直接操作栈则基本没有内核切换的开销,可以不加锁的访问全局变量,所以上下文的切换非常快。

9. GPM 调度 和 CSP 模型

GPM模型是Golang目前正在使用的模型

内核空间:创建多个操作系统内核线程 (这个是底层实现的,我们改变不了,只能进行使用)
用户空间:创建多个协程 (语言层面,不同的语言做出来的协程调度器是不一样的)

此时就引入了协程与线程比例数

  • 协程:线程 = M :1,就是多个协程对应一个线程,此时如果一个协程阻塞了,那么其他协程还是会进行等待,这样解决不了问题
  • 协程:线程 = 1 : 1,就是一个协程对应一个线程,此时还不如不要协程,创建一个协程就得创建一个线程,这样的直接用线程是一样的

  • 协程:线程 = M : N,就是多个协程对应多个线程,协程的数量肯定是比线程多得多,所以GPM模型就采用的M :N的方式

  • ————————————————

  • 版权声明:本文为CSDN博主「钢钢钢很不爽」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。

  • 原文链接:https://blog.csdn.net/weixin_45672178/article/details/113767196

2.1 CSP 模型?

CSP 模型是“以通信的方式来共享内存”,不同于传统的多线程通过共享内存来通信。用于描述两个独立的并发实体通过共享的通讯 channel (管道)进行通信的并发模型。

2.2 GPM 分别是什么、分别有多少数量?

  • G(Goroutine): 即 Go 协程,每个 go 关键字都会创建一个协程。
  • 8M(Machine):工作线程,在 Go 中称为 Machine,数量对应真实的 CPU 数(真正干活的对象)默认10000.。runtime/debug中的SetMaxThreads函数,设置M的最大数量
  • P(Processor): 处理器(Go 中定义的一个摡念,非 CPU),包含运行 Go 代码的必要资源,用来调度 G 和 M 之间的关联关系,其数量可通过 GOMAXPROCS() 来设置,默认为CPU核心数。由启动时环境变量$GOMAXPROCS或者是由runtime的方法`GOMAXPROCS()

3.GPM调度流程

=

从上图我们可以分析出几个结论:

1)我们通过 go func () 来创建一个 goroutine;

2)有两种存储 G 的队列,一个是局部调度器 P 的本地队列、一个是全局 G 队列;新创建的 G 会先保存在 P 的本地队列中,如果 P 的本地队列已经满了就会保存在全局的队列中;

3)G 只能运行在 M 中,一个 M 必须持有一个 P,M 与 P 是 1:1 的关系;M 会从 P 的本地队列弹出一个 G 来执行,如果 P 的本地队列为空,就会想其他的 MP 组合偷取一个可执行的 G;

4) 一个 M 调度 G 执行的过程是一个循环机制;

img

1.系统调度引起阻塞:
当 M 执行某一个 G 时候如果发生了 syscall 如GC,M 会阻塞;M就会继续处理这个进入系统调用的G协程,但是他会释放掉自己关联的P本地队列,同时会有新的M去接替执行这个P本地队列如果当前有一些 G 在执行,当 M 系统调用结束时候,这个 G 会尝试获取一个空闲的 P 执行,并放入到这个 P 的本地队列;如果获取不到 P,那么这个线程 M 变成休眠状态, 加入到空闲线程中,然后这个 G 会被放入全局队列中。

2.用户态的阻塞:
当goroutine因为管道操作或者系统IO、网络IO而阻塞时,对应的G会被放置到某个等待队列,该G的状态由运行时变为等待状态,而M会跳过该G尝试获取并执行下一个G,如果此时没有可运行的G供M运行,那么M将解绑P,并进入休眠状态;当阻塞的G被另一端的G2唤醒时,如管道通知-=0G又被标记为可运行状态,尝试加入G2所在P局部队列的队头,然后再是G全局队列。

6)当存在空闲的P时,窃取其他队列的G:当P维护的局部队列全部运行完毕,它会尝试在全局队列获取G,直到全局队列为空,再向其他局部队列窃取一般的G。

————————————————
版权声明:本文为CSDN博主「良中子」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.csdn.net/weixin_35655661/article/details/119573864

(2)调度器的设计策略

复用线程:避免频繁的创建、销毁线程,而是对线程的复用。

1)work stealing机制

当本线程无可运行的G时,尝试从其他线程绑定的P偷取G,而不是销毁线程。

2)hand off机制

当本线程因为G进行系统调用阻塞时,线程释放绑定的P,把P转移给其他空闲的线程执行。

利用并行GOMAXPROCS设置P的数量,最多有GOMAXPROCS个线程分布在多个CPU上同时运行。GOMAXPROCS也限制了并发的程度,比如GOMAXPROCS = 核数/2,则最多利用了一半的CPU核进行并行。

抢占:在coroutine中要等待一个协程主动让出CPU才执行下一个协程,在Go中,一个goroutine最多占用CPU 10ms,防止其他goroutine被饿死,这就是goroutine不同于coroutine的一个地方。

全局和本地队列:在新的调度器中依然有全局G队列,但功能已经被弱化了,当M执行work stealing从其他P偷不到G时,它可以从全局G队列获取G。

10. 垃圾回收

https://www.jianshu.com/p/8b0c0f7772da

1.1 常见的垃圾回收算法:

  • 引用计数:每个对象维护一个引用计数,当被引用对象被创建或被赋值给其他对象时引用计数自动加 +1;如果这个对象被销毁,则计数 -1 ,当计数为 0 时,回收该对象。
    • 优点:对象可以很快被回收,不会出现内存耗尽或到达阀值才回收。
    • 缺点:不能很好的处理循环引用
  • 标记-清除:从根变量开始遍历所有引用的对象,引用的对象标记“被引用”,没有被标记的则进行回收。
    • 优点:解决了引用计数的缺点。
    • 缺点:需要 STW(stop the world),暂时停止程序运行。
  • 分代收集:按照对象生命周期长短划分不同的代空间,生命周期长的放入老年代,短的放入新生代,不同代有不同的回收算法和 回收频率。
    • 优点:回收性能好
    • 缺点:算法复杂
  • 对于 Go 而言,Go 的 GC 目前使用的是无分代(对象没有代际之分)、不整理(回收过程中不对对象进行移动与整理)、并发(与用户代码并发执行)的三色标记清扫算法

1.2 三色标记法

  • 初始状态下所有对象都是白色的。
  • 从根节点开始遍历所有对象,把遍历到的对象变成灰色对象
  • 遍历灰色对象,将灰色对象引用的对象也变成灰色对象,然后将遍历过的灰色对象变成黑色对象。
  • 循环步骤 3,直到灰色对象全部变黑色。
  • 通过写屏障(write-barrier)检测对象有变化,重复以上操作
  • 收集所有白色对象(垃圾)。

1.3 STW(Stop The World)

  • 为了避免在 GC 的过程中,对象之间的引用关系发生新的变更,使得 GC 的结果发生错误(如 GC 过程中新增了一个引用,但是由于未扫描到该引用导致将被引用的对象清除了),停止所有正在运行的协程。
  • STW 对性能有一些影响,Golang 目前已经可以做到 1ms 以下的 STW。

1.4 写屏障(Write Barrier)

  • 为了避免 GC 的过程中新修改的引用关系到 GC 的结果发生错误,我们需要进行 STW。但是 STW 会影响程序的性能,所以我们要通过写屏障技术尽可能地缩短 STW 的时间。
造成引用对象丢失的条件
  1. 一个黑色的节点A新增了指向白色节点C的引用
  2. 并且白色节点C没有除了A之外的其他灰色节点的引用,或者存在但是在GC过程中被删除了

以上两个条件需要同时满足:满足条件1时说明节点A已扫描完毕,A指向C的引用无法再被扫描到;满足条件2时说明白色节点C无其他灰色节点的引用了,即扫描结束后会被忽略 。

写屏障破坏两个条件其一即可

  1. 破坏条件1 => Dijistra写屏障
  • 满足强三色不变性:黑色节点不允许引用白色节点
  • 当黑色节点新增了白色节点的引用时,将对应的白色节点改为灰色
  • 不足:结束时需要使用STW来重新扫描栈
  1. 破坏条件2 => Yuasa写屏障
  • 满足弱三色不变性:黑色节点允许引用白色节点,但是该白色节点有其他灰色节点间接的引用(确保不会被遗漏)
  • 当白色节点被删除了一个引用时,悲观地认为它一定会被一个黑色节点新增引用,所以将它置为灰色
  • 删除屏障的不足:回收精度低,一个对象即使被删除了最后一个指向它的指针也依旧可以活过这一轮,在下一轮GC中被清理掉。

11、mutext、rwmutext

mutex 状态标志位

mutex 的 state 有 32 位,它的低 3 位分别表示 3 种状态:唤醒状态、上锁状态、饥饿状态,剩下的位数则表示当前阻塞等待的 goroutine 数量。

mutex 会根据当前的 state 状态来进入正常模式、饥饿模式或者是自旋

mutex 正常模式

当 mutex 调用 Unlock() 方法释放锁资源时,如果发现有等待唤起的 Goroutine 队列时,则会将队头的 Goroutine 唤起。

队头的 goroutine 被唤起后,会调用 CAS 方法去尝试性的修改 state 状态,如果修改成功,则表示占有锁资源成功。

(注:CAS 在 Go 里用 atomic.CompareAndSwapInt32(addr *int32, old, new int32) 方法实现,CAS 类似于乐观锁作用,修改前会先判断地址值是否还是 old 值,只有还是 old 值,才会继续修改成 new 值,否则会返回 false 表示修改失败。)

mutex 饥饿模式

由于上面的 Goroutine 唤起后并不是直接的占用资源,还需要调用 CAS 方法去尝试性占有锁资源。如果此时有新来的 Goroutine,那么它也会调用 CAS 方法去尝试性的占有资源。

但对于 Go 的调度机制来讲,会比较偏向于 CPU 占有时间较短的 Goroutine 先运行,而这将造成一定的几率让新来的 Goroutine 一直获取到锁资源,此时队头的 Goroutine 将一直占用不到,导致饿死。

针对这种情况,Go 采用了饥饿模式。即通过判断队头 Goroutine 在超过一定时间后还是得不到资源时,会在 Unlock 释放锁资源时,直接将锁资源交给队头 Goroutine,并且将当前状态改为饥饿模式。

后面如果有新来的 Goroutine 发现是饥饿模式时, 则会直接添加到等待队列的队尾。

mutex 自旋

如果 Goroutine 占用锁资源的时间比较短,那么每次都调用信号量来阻塞唤起 goroutine,将会很浪费资源。

因此在符合一定条件后,mutex 会让当前的 Goroutine 去空转 CPU,在空转完后再次调用 CAS 方法去尝试性的占有锁资源,直到不满足自旋条件,则最终会加入到等待队列里。

自旋的条件如下:

  • 还没自旋超过 4 次
  • 多核处理器
  • GOMAXPROCS > 1
  • p 上本地 Goroutine 队列为空
    可以看出,自旋条件还是比较严格的,毕竟这会消耗 CPU 的运算能力。

mutex 的 Lock() 过程

首先,如果 mutex 的 state = 0,即没有谁在占有资源,也没有阻塞等待唤起的 goroutine。则会调用 CAS 方法去尝试性占有锁,不做其他动作。

如果不符合 m.state = 0,则进一步判断是否需要自旋。

当不需要自旋又或者自旋后还是得不到资源时,此时会调用 runtime_SemacquireMutex 信号量函数,将当前的 goroutine 阻塞并加入等待唤起队列里。

当有锁资源释放,mutex 在唤起了队头的 goroutine 后,队头 goroutine 会尝试性的占有锁资源,而此时也有可能会和新到来的 goroutine 一起竞争。

当队头 goroutine 一直得不到资源时,则会进入饥饿模式,直接将锁资源交给队头 goroutine,让新来的 goroutine 阻塞并加入到等待队列的队尾里。

对于饥饿模式将会持续到没有阻塞等待唤起的 goroutine 队列时,才会解除。

Unlock 过程
mutex 的 Unlock() 则相对简单。同样的,会先进行快速的解锁,即没有等待唤起的 goroutine,则不需要继续做其他动作。

如果当前是正常模式,则简单的唤起队头 Goroutine。如果是饥饿模式,则会直接将锁交给队头 Goroutine,然后唤起队头 Goroutine,让它继续运行。

posted @ 2022-07-11 20:57  hyz00  阅读(43)  评论(0编辑  收藏  举报