var arr [5]int
。make
函数或使用切片字面量定义,例如 s := make([]int, 5)
或 s := []int{1, 2, 3}
。扩容是为切片分配新的内存空间并复制原切片中元素的过程。在 go 语言的切片中,扩容的过程是:估计大致容量 -> 确定容量 -> 覆盖原切片 -> 完成扩容。
Go 的切片(slice)类型本身并不是线程安全的。多个 goroutine 并发地对同一个切片进行读写操作可能会导致数据竞争和不确定的结果。如果需要在并发环境下安全地使用切片,可以采取以下几种方式:
atomic.AddInt32
、atomic.LoadPointer
等,可以确保在并发环境下对切片的操作是原子的。有可能分配到栈上,也有可能分配到栈上。当开辟切片空间较大时,会逃逸到堆上。
切片的扩容, 当在尾部扩容时,追加元素,不需要重新写入;
var a []int
a = append(a, 1)
在头部插入时;会引起内存的重分配,导致已有的元素全部重新写入;
a = append([]int{0}, a...);
在中间插入时,会局部重新写入,如下: 使用链式操作在插入元素,在内层append函数中会创建一个临式切片,然后将a[i:]内容复制到新创建的临式切片中,再将临式切片追加至a[:i]中。
a = append(a[:i], append([]int{x}, a[i:]...)...)
a = append(a[:i], append([]int{1, 2, 3}, a[i:]...)...)//在第i个位置上插入切片
拷贝的是数据本身,创造一个样的新对象,新创建的对象与原对象不共享内存,新创建的对象在内存中开辟一个新的内存地址,新对象值修改时不会影响原对象值。既然内存地址不同,释放内存地址时,可分别释放。
拷贝的是数据地址,只复制指向的对象的指针,此时新对象和老对象指向的内存地址是一样的,新对象值修改时老对象也会变化。释放内存地址时,同时释放内存地址。参考来源 (opens new window)在go语言中值类型赋值都是深拷贝,引用类型一般都是浅拷贝:
对于引用类型,想实现深拷贝,不能直接 := ,而是要先开辟地址空间(new) ,再进行赋值。可以使用 copy() 函数对slice进行深拷贝,copy 不会进行扩容,当要复制的 slice 比原 slice 要大的时候,只会移除多余的。使用 append() 函数来进行深拷贝,append 会进行扩容
Map 是一种无序的键值对的集合。Map 可以通过 key 来快速检索数据,key 类似于索引,指向数据的值。 而 Slice 是切片,可以改变长度,动态扩容,切片有三个属性,指针,长度,容量。 二者都可以用 make 进行初始化。
Go中Map是一个KV对集合。底层使用hash table,用链表来解决冲突 ,出现冲突时,不是每一个Key都申请一个结构通过链表串起来,而是以bmap为最小粒度挂载,一个bmap可以放8个kv。每个map的底层结构是hmap,是有若干个结构为bmap的bucket组成的数组。每个bucket底层都采用链表结构。bmap 就是我们常说的“桶”,桶里面会最多装 8 个 key,这些 key之所以会落入同一个桶,是因为它们经过哈希计算后,哈希结果是“一类”的,关于key的定位我们在map的查询和赋值中详细说明。在桶内,又会根据key计算出来的hash值的高8位来决定 key到底落入桶内的哪个位置(一个桶内最多有8个位置)。
map[key]value
,其中key必须是可比较的,也就是可以通过==
和!=
进行比较,所以可以比较的类型才能作为key,其实就是等价问go语言中哪些类型是可以比较的:
什么可以比较:bool、array、numeric(浮点数、整数等)、pointer、string、interface、channel
什么不能比较:function、slice、map
golang中的map,的 key 可以是很多种类型,比如 bool, 数字,string, 指针, channel , 还有 只包含前面几个类型的 interface types, structs, arrays; map是可以进行嵌套的。
使用reflect.DeepEqual 这个函数进行比较。使用 reflect.DeepEqual 有一点注意:由于使用了反射,所以有性能的损失。
type hmap struct {
count int //元素的个数
flags uint8 //状态标志
B uint8 //可以最多容纳 6.5 * 2 ^ B 个元素,6.5为装载因子
noverflow uint16 //溢出的个数
hash0 uint32 //哈希种子
buckets unsafe.Pointer //指向一个桶数组
oldbuckets unsafe.Pointer //指向一个旧桶数组,用于扩容
nevacuate uintptr //搬迁进度,小于nevacuate的已经搬迁
overflow *[2]*[]*bmap //指向溢出桶的指针
}
type bmap struct {
//元素hash值的高8位代表它在桶中的位置,如果tophash[0] < minTopHash,表示这个桶的搬迁状态
tophash [bucketCnt]uint8
//接下来是8个key、8个value,但是我们不能直接看到;为了优化对齐,go采用了key放在一起,value放在一起的存储方式,
keys [8]keytype //key单独存储
values [8]valuetype //value单独存储
pad uintptr
overflow uintptr //指向溢出桶的指针
}
负载因子用于衡量一个哈希表冲突情况,公式为:
负载因子 = 键数量/bucket数量
例如,对于一个bucket数量为4,包含4个键值对的哈希表来说,这个哈希表的负载因子为1.哈希表需要将负载因子控制在合适的大小,超过其阀值需要进行rehash,也即键值对重新组织:
每个哈希表的实现对负载因子容忍程度不同,比如Redis实现中负载因子大于1时就会触发rehash,而Go则在在负载因子达到6.5时才会触发rehash,因为Redis的每个bucket只能存1个键值对,而Go的bucket可能存8个键值对,所以Go可以容忍更高的负载因子。
在Go语言中,普通的map
类型在哈希冲突的情况下采用了开链法(链地址法)来解决。当不同的键经过哈希计算后映射到了同一个桶(bucket)时,就会产生哈希冲突。为了解决这些冲突,每个桶会维护一个链表,将哈希值相同的键值对链接在一起。以下是哈希冲突如何在Go中的普通map
中解决的简要过程:
map
底层使用了一个哈希表,这个哈希表由多个桶组成。新建一个bucket数组,新的bucket数组的长度是原来的两倍,然后旧bucket数组中的数据搬迁到新的bucket数组中。考虑到如果map存储了数以亿计的key-value,一次性搬迁将会造成比较大的延时,Go采用逐步搬迁策略,即每次访问map时都会触发一次搬迁,每次搬迁2个键值对。
所谓等量扩容,实际上并不是扩大容量,buckets数量不变,重新做一遍类似增量扩容的搬迁动作,把松散的键值对重新排列一次,以使bucket的使用率更高,进而保证更快的存取。
Map默认不是并发安全的,并发读写时程序会panic。map为什么不支持线程安全?和场景有关,官方认为大部分场景不需要多个协程进行并发访问,如果为小部分场景加锁实现并发访问,大部分场景将付出加锁代价(性能降低)。
加读写锁、分片加锁,这两种方案都比较常用,后者的性能更好,因为它可以降低锁的粒度,提高访问此 map 对象的吞吐。前者并发性能虽然不如后者, 但是加锁的方式更加简单。sync.Map 是 Go 1.9 增加的一个线程安全的 map ,虽然是官方标准,但反而是不常用的,原因是 map 要解决的场景很难 描述,很多时候程序员在做抉择是否该用它,不过在一些特殊场景会使用 sync.Map,场景一:只会增长的缓存系统,一个 key 值写入一次而被读很多次; 场景二:多个 goroutine 为不相交的键读、写和重写键值对。对它的使用场景介绍,来自官方文档 (opens new window),这里就不展开了。 加读写锁,扩展 map 来实现线程安全,支持并发读写。使用读写锁 RWMutex,是为了读写性能的考虑。 对 map 对象的操作,无非就是常见的增删改查和遍历。我们可以将查询和遍历看作读操作,增加、修改和 删除看作写操作。示例代码链接:https://github.com/guowei-gong/go-demo/blob/main/mutex/demo.go 。通过读写锁提供线程安全的 map,但是大量并发读写的情况下,锁的竞争会很激烈,导致性能降低。如何解决这个问题? 尽量减少锁的粒度和锁的持有时间,减少锁的粒度,常用方法就是分片 Shard,将一把锁分成几把锁,每个锁控制一个分片。
sync.map采用读写分离和用空间换时间的策略保证map的读写安全。
sync.Map
的底层使用了一个散列桶数组来存储键值对。这个数组被划分成多个小的片段,每个片段有自己的锁,这样不同的片段可以独立地进行操作,从而减少了竞争。sync.Map
实现了一种读写分离的机制。在读取时,不需要锁定,多个goroutine可以并发读取。写操作涉及到写入数据,会获取特定散列桶的写锁。sync.Map
使用散列算法将键映射到散列桶。每个散列桶中都可能包含多个键值对,因此可能会出现散列冲突。冲突的解决方式通常是通过链表来存储具有相同散列的键值对。sync.Map
引入了版本控制的概念。每个散列桶中都包含了一个版本号,用于跟踪对散列桶的修改。这使得在读取时可以检测到同时进行的写入,从而确保读取的数据的一致性。sync.Map
还包含了一些内存管理机制,以避免不再使用的内存积累。当某个散列桶不再被使用时,相应的内存可能会被释放。基本结构:
type Map struct {
mu Mutex
read atomic.Value //包含对并发访问安全的map内容的部分(无论是否持有mu)
dirty map[ant]*entry //包含map内容中需要保存mu的部分
misses int //计算自从上次读取map更新后,需要锁定mu来确定key是否存在的加载次数
}
read:read 使用 map[any]*entry
存储数据,本身支持无锁的并发读read 可以在无锁的状态下支持 CAS 更新,但如果更新的值是之前已经删除过的 entry 则需要加锁操作由于 read 只负责读取,dirty 负责写入,因此使用 amended
来标记 dirty 中是否包含 read 没有的字段
**dirty:**dirty 本身就是一个原生 map,需要加锁保证并发写入
**entry:**read 和 dirty 都是用到 entry 结构entry 内部只有一个 unsafe.Pointer 指针 p 指向 entry 实际存储的值指针 p 有三种状态
p == nil
在此状态下对应: entry 已经被删除 或 map.dirty == nil 或 map.dirty 中有 key 指向 e 此处不明
p == expunged
在此状态下对应:entry 已经被删除 或 map.dirty != nil 同时该 entry 无法在 dirty 中找到
其他情况
entry 都是有效状态并被记录在 read 中,如果 dirty 不为空则也可以在 dirty 中找到
map
是非线程安全的,多个 goroutine 并发地读写 map
可能会导致数据竞争和不确定的结果。而 sync.Map
是线程安全的,可以在多个 goroutine 并发地读写 sync.Map
,而不需要额外的同步操作。map
的扩容是在插入新元素时自动进行的,按需增加内部哈希表的大小。而 sync.Map
不会自动扩容,它始终使用固定大小的内部哈希表。map
提供了常见的读取、插入、更新和删除等操作,如 m[key]
、m[key] = value
、delete(m, key)
等。而 sync.Map
提供了一组特定的方法,如 Load
、Store
、Delete
和 Range
,用于读取、存储、删除和遍历键值对。sync.Map
是线程安全的,它需要进行额外的同步操作,因此在并发性能方面可能会比普通的 map
稍慢。而普通的 map
在单个 goroutine 下的读取和写入操作性能较高查找过程如下:
注:如果查找不到,也不会返回空值,而是返回相应类型的0值。
新元素插入过程如下:
在map查询操作中,最多可以给两个变量赋值,第一个为值,第二个为bool类型的变量,用于指示是否存在指定的键,如果键不存在,那么第一个值为相应类型的零值。如果只指定一个变量,那么该变量仅表示改键对应的值,如果键不存在,那么该值同样为相应类型的零值。
1. Set原理: Set特性: 1. 不包含重复key. 2.无序. 如何去重: 通过查看源码add(E e)方法,底层实现有一个map,map是HashMap,Hash类型是散列,所以是无序的. 如果key值相同,将会覆盖,这就是set为什么能去重的原因(key相同会覆盖). 注意: 如果new出两个对象add到set中,因为两个对象的地址不相同,所以map在计算key的hash值时,将它当成了两个不同的元素。这时要重写equals和hashcode两个方法。 这样才能保证set集合的元素不重复.
2. Java HashMap:
线程不安全 安全的map(CurrentHashMap) HashMap由数组+链表组成,数组是HashMap的主体, 链表则是为了解决哈希冲突而存在的,如果定位到的数组位置不含链表(当前entry的next指向null),那么查找,添加等操作很快,仅需一次寻址即可; 如果定位到的数组包含链表,对于添加操作,其时间复杂度为O(n),首先遍历链表,存在即覆盖,否则新增; 对于查找操作来讲,仍需遍历链表,然后通过key对象的equals方法逐一比对查找。 所以,性能考虑,HashMap中的链表出现越少,性能才会越好。 假如一个数组槽位上链上数据过多(即链表过长的情况)导致性能下降该怎么办? JDK1.8在JDK1.7的基础上针对增加了红黑树来进行优化。 即当链表超过8时,链表就转换为红黑树,利用红黑树快速增删改查的特点提高HashMap的性能,其中会用到红黑树的插入、删除、查找等算法。
3. go map:
线程不安全 安全的map(sync.map) 特性: 1. 无序. 2. 长度不固定. 3. 引用类型. 底层实现: 1.hmap 2.bmap(bucket) hmap中含有n个bmap,是一个数组. 每个bucket又以链表的形式向下连接新的bucket. bucket关注三个字段: 1. 高位哈希值 2. 存储key和value的数组 3. 指向扩容bucket的指针 高位哈希值: 用于寻找bucket中的哪个key. 低位哈希值: 用于寻找当前key属于hmap中的哪个bucket. map的扩容: 当map中的元素增长的时候,Go语言会将bucket数组的数量扩充一倍,产生一个新的bucket数组,并将旧数组的数据迁移至新数组。 加载因子 判断扩充的条件,就是哈希表中的加载因子(即loadFactor)。 加载因子是一个阈值,一般表示为:散列包含的元素数 除以 位置总数。是一种“产生冲突机会”和“空间使用”的平衡与折中:加载因子越小,说明空间空置率高,空间使用率小,但是加载因子越大,说明空间利用率上去了,但是“产生冲突机会”高了。 每种哈希表的都会有一个加载因子,数值超过加载因子就会为哈希表扩容。 Golang的map的加载因子的公式是:map长度 / 2^B(这是代表bmap数组的长度,B是取的低位的位数)阈值是6.5。其中B可以理解为已扩容的次数。 当Go的map长度增长到大于加载因子所需的map长度时,Go语言就会将产生一个新的bucket数组,然后把旧的bucket数组移到一个属性字段oldbucket中。注意:并不是立刻把旧的数组中的元素转义到新的bucket当中,而是,只有当访问到具体的某个bucket的时候,会把bucket中的数据转移到新的bucket中。 map删除: 并不会直接删除旧的bucket,而是把原来的引用去掉,利用GC清除内存。
channel是Golang在语言层面提供的goroutine间的通信方式,channel主要用于进程内各goroutine间的通信。channel分为无缓冲channel和有缓冲channel。
Channel 在 gouroutine 间架起了一条管道,在管道里传输数据,实现 gouroutine 间的通信;在并发编程中它线程安全的,所以用起来非常方便;channel 还提供“先进先出”的特性;它还能影响 goroutine 的阻塞和唤醒。
type hchan struct {
qcount uint // 当前队列中剩余元素个数
dataqsiz uint // 环形队列长度,即可以存放的元素个数
buf unsafe.Pointer // 环形队列指针
elemsize uint16 // 每个元素的大小
closed uint32 // 标识关闭状态
elemtype *_type // 元素类型
sendx uint // 队列下标,指示元素写入时存放到队列中的位置
recvx uint // 队列下标,指示元素从队列的该位置读出
recvq waitq // 等待读消息的goroutine队列
sendq waitq // 等待写消息的goroutine队列
lock mutex // 互斥锁,chan不允许并发读写
}
从数据结构可以看出channel由队列、类型信息、goroutine等待队列组成,channel内部数据结构主要包含:
chan内部实现了一个环形队列作为其缓冲区,队列的长度是创建chan时指定的。
从channel读数据,如果channel缓冲区为空或者没有缓冲区,当前goroutine会被阻塞。
向channel写数据,如果channel缓冲区已满或者没有缓冲区,当前goroutine会被阻塞。
被阻塞的goroutine将会挂在channel的等待队列中:
向一个channel中写数据简单过程如下:
关闭channel时会把recvq中的G全部唤醒,本该写入G的数据位置为nil。把sendq中的G全部唤醒,但这些G会panic。
除此之外,panic出现的常见场景还有:
并发问题可以用channel解决也可以用Mutex解决,但是它们的擅长解决的问题有一些不同。channel关注的是并发问题的数据流动,适用于数据在多个协程中流动的场景。而mutex关注的是是数据不动,某段时间只给一个协程访问数据的权限,适用于数据位置固定的场景。
channel适用于数据在多个协程中流动的场景,有很多实际应用:
无缓冲:发送和接收需要同步。 有缓冲:不要求发送和接收同步,缓冲满时发送阻塞。 因此 channel 无缓冲时,发送阻塞直到数据被接收,接收阻塞直到读到数据;channel有缓冲时,当缓冲满时发送阻塞,当缓冲空时接收阻塞。
分布式锁定义-控制分布式系统有序的去对共享资源进行操作,通过互斥来保持一致性。 通过数据库,redis,zookeeper都可以实现分布式锁。其中,最常见的是用redis的setnx实现。
通过channel作为媒介,利用struct{}{}作为信号,判断struct{}{}是否存在进行加锁、解锁操作。
func Merge(ch1 <-chan int, ch2 <-chan int) <-chan int {
out := make(chan int)
go func() {
// 等上游的数据 (这里有阻塞,和常规的阻塞队列并无不同)
v1, ok1 := <-ch1
v2, ok2 := <-ch2
// 取数据
for ok1 || ok2 {
if !ok2 || (ok1 && v1 <= v2) {
// 取到最小值, 就推到 out 中
out <- v1
v1, ok1 = <-ch1
} else {
out <- v2
v2, ok2 = <-ch2
}
}
// 显式关闭
close(out)
}()
// 开完goroutine后, 主线程继续执行, 不会阻塞
return out
方式1:通过读chennel实现
用 select 和 <-ch 来结合判断,ok的结果和含义: true:读到数据,并且通道 (opens new window)没有关闭。 false:通道关闭,无数据读到。需要注意: 1.case 的代码必须是 _, ok:= <- ch 的形式,如果仅仅是 <- ch 来判断,是错的逻辑,因为主要通过 ok的值来判断; 2.select 必须要有 default 分支,否则会阻塞函数,我们要保证一定能正常返回;
方式2:通过context
通过一个 ctx 变量来指明 close 事件,而不是直接去判断 channel 的一个状态. 当ctx.Done()中有值时,则判断channel已经退出。注意: select 的 case 一定要先判断 ctx.Done() 事件,否则很有可能先执行了 chan 的操作从而导致 panic 问题;
Go的设计思想就是, 不要通过共享内存来通信,而是通过通信来共享内存,前者就是传统的加锁,后者就是Channel。 共享内存是在操作内存的同时,通过互斥锁、CAS等保证并发安全,而channel虽然底层维护了一个互斥锁,来保证线程安全,但其可以理解为先进先出的队列,通过管道进行通信。 共享内存优势是资源利用率高、系统吞吐量大,劣势是平均周转时间长、无交互能力。 channel优势是降低了并发中的耦合,劣势是会出现死锁。
// 空结构体的宽度是0,占用了0字节的内存空间。
// 所以空结构体组成的组合数据类型也不会占用内存空间。
channel := make(chan struct{})
go func() {
// do something
channel <- struct{}{}
}()
fmt.Println(<-channel)
hannel的底层也是用了syns.Mutex,算是对锁的封装,性能应该是有损耗的。根据压测结果来说Mutex 比 channel的性能快了两倍左右
同一个协程里,不能对无缓冲channel同时发送和接收数据,如果这么做会直接报错死锁。对于一个无缓冲的channel而言,只有不同的协程之间一方发送数据一方接受数据才不会阻塞。channel无缓冲时,发送阻塞直到数据被接收,接收阻塞直到读到数据。
在此模型下的用户线程与内核线程一一对应,也就是说完全接管了用户线程,它也属于内核的一部分,统一由调度器来创建、终止和切换。这样就能完全发挥出多核的优势,多个线程可以跑在不同的CPU上,实现真正的并行。但也正由于一切都由内核来调度,这样大大增加了工作量,线程的切换是非常耗时的,而且创建也很用到更多的资源,所以也大大减少能创建线程的数量。由于是一对一的关系所以也叫(1:1)线程实现。
G(Goroutine)
:G 就是我们所说的 Go 语言中的协程 Goroutine 的缩写,相当于操作系统中的进程控制块。其中存着 goroutine 的运行时栈信息,CPU 的一些寄存器的值以及执行的函数指令等。M(Machine)
:代表一个操作系统的主线程,对内核级线程的封装,数量对应真实的 CPU 数。一个 M 直接关联一个 os 内核线程,用于执行 G。M 会优先从关联的 P 的本地队列中直接获取待执行的 G。M 保存了 M 自身使用的栈信息、当前正在 M上执行的 G 信息、与之绑定的 P 信息。P(Processor)
:Processor 代表了 M 所需的上下文环境,代表 M 运行 G 所需要的资源。是处理用户级代码逻辑的处理器,可以将其看作一个局部调度器使 go 代码在一个线程上跑。当 P 有任务时,就需要创建或者唤醒一个系统线程来执行它队列里的任务,所以 P 和 M 是相互绑定的。总的来说,P 可以根据实际情况开启协程去工作,它包含了运行 goroutine 的资源,如果线程想运行 goroutine,必须先获取 P,P 中还包含了可运行的 G 队列。1.介绍golang调度器中P是什么?
Processor的简称,处理器,上下文。
2.简述p的功能与为什么必须要P
它的主要用途就是用来执行goroutine的,它维护了一个goroutine队列。Processor是让咱们从N:1调度到M:N调度的重要部分
在 Go 语言中,P(Processor)是调度器的一部分,用于管理和执行 goroutine。每个 P 都有一个固定的系统线程(OS thread)关联,用于在该线程上执行 goroutine。P 的存在是为了协调调度器和系统线程之间的关系,它充当了调度器和操作系统之间的中间层。P 的作用包括:
由于 Go 语言的调度器是基于 M:N 模型实现的,即将 M 个 goroutine 关联到 N 个系统线程上执行,因此不能直接在没有 P 的情况下运行 goroutine。
谈到 Go 语言调度器,绕不开操作系统,进程与线程这些概念。线程是操作系统调度的最小单元,而 Linux 调度器并不区分进程和线程的调度,它们在不同操作系统上的实现也不同,但是在大多数实现中线程属于进程。多个线程可以属于同一个进程并共享内存空间。因为多线程不需要创建新的虚拟内存空间,所以它们也不需要内存管理单元处理上下文的切换,线程之间的通信也正是基于共享内存进行的,与重量级进程相比,线程显得比较轻量。虽然线程比较轻量,但是在调度时也有比较大的额外开销。每个线程会都占用 1MB 以上的内存空间,在切换线程时不止会消耗较多内存,恢复寄存器中的内存还需要向操作系统申请或者销毁资源。每一个线程上下文的切换都需要消耗 1 us 的时间,而 Go 调度器对 Goroutine 的上下文切换越为 0.2us,减少了 80% 的额外开销。Go 语言的调度器使用与 CPU 数量相等的线程来减少线程频繁切换带来的内存开销,同时在每一个线程上执行额外开销更低的 Goroutine 来降低操作系统和硬件的负载。
当g阻塞时,p会和m解绑,去寻找下一个可用的m。 g&m在阻塞结束之后会优先寻找之前的p,如果此时p已绑定其他m,当前m会进入休眠,g以可运行的状态进入全局队列
绑定在P上的local queue是顺序执行的,不存在执行状态的G协程抢占,所以可以无锁访问。任务窃取也是窃取其他P上等待状态的G协程,所以也可以不用加锁。
Go的调度器内部有三个重要的结构,G(代表一个goroutine,它有自己的栈),M(Machine,代表内核级线程),P(Processor([prɑːsesər]),上下文处理器,它的主要用途就是用来连接执行的goroutine和内核线程的,定义在源码的src/runtime/runtime.h文件中) -G代表一个goroutine对象,每次go调用的时候,都会创建一个G对象 -M代表一个线程,每次创建一个M的时候,都会有一个底层线程创建;所有的G任务,最终还是在M上执行 -P代表一个处理器,每一个运行的M都必须绑定一个P,就像线程必须在每一个CPU核上执行一样 一个M对应一个P,一个P下面挂多个G,但同一时间只有一个G在跑,其余都是放入等待队列(runqueue([kjuː]))。 当一个P的队列消费完了就去全局队列里取,如果全局队列里也消费完了会去其他P的队列里抢任务(所以需要单独存储下一个 g 的地址,而不是从队列里获取)。
首先一万个G会按照P的设定个数,尽量平均地分配到每个P的本地队列中。如果所有本地队列都满了,那么剩余的G则会分配到GMP的全局队列上。接下来便开始执行GMP模型的调度策略:
在1.1 版本中的调度器是不支持抢占式调度的,程序只能依靠 Goroutine 主动让出 CPU 资源才能触发调度。Go 语言的调度器在 1.2 版本中引入基于协作的抢占式调度,解决了以下的问题:
1.2 版本的抢占式调度虽然能够缓解这个问题,但是它实现的抢占式调度是基于协作的,在之后很长的一段时间里 Go 语言的调度器都有一些无法被抢占的边缘情况,例如:for 循环或者垃圾回收长时间占用线程,这些问题中的一部分直到 1.14 才被基于信号的抢占式调度解决。 抢占式分为两种:
Goroutine 作为一种逻辑上理解的轻量级线程,需要维护执行用户代码的上下文信息。在运行过程中也需要消耗一定的内存来保存这类信息,而这些内存在目前版本的 Go 中是不会被释放的。因此,如果一个程序持续不断地产生新的 goroutine、且不结束已经创建的 goroutine 并复用这部分内存,就会造成内存泄漏的现象。造成泄露的大多数原因有以下三种:
不一定,M必须持有P才可以执行代码,跟系统中的其他线程一样,M也会被系统调用阻塞。P的个数在启动程序时决定,默认情况下等于CPU的核数,可以使用环境变量GOMAXPROCS或在程序中使用runtime.GOMAXPROCS()方法指定P的个数。 M的个数通常稍大于P的个数,因为除了运行Go代码,runtime包还有其他内置任务需要处理。
对于进程、线程,都是有内核进行调度,有CPU时间片的概念,进行抢占式调度。协程,又称微线程,纤程。英文名Coroutine。协程的调用有点类似子程序,如程序A调用了子程序B,子程序B调用了子程序C,当子程序C结束了返回子程序B继续执行之后的逻辑,当子程序B运行结束了返回程序A,直到程序A运行结束。但是和子程序相比,协程有挂起的概念,协程可以挂起跳转执行其他协程,合适的时机再跳转回来。 本质上goroutine就是协程,但是完全运行在用户态,采用了MPG模型:
M:内核级线程
G:代表一个goroutine
P:Processor,处理器,用来管理和执行goroutine的。
G-M-P三者的关系与特点:
全局队列中的G不会饥饿,P中每执行61次调度,就需要优先从全局队列中获取一个G到当前P中,并执行下一个要执行的G。
P数量问题可以通过 runtime.GOMAXPROCS() 设置数量,默认为当前CPU可执行的最大数量。M数量问题 Go语⾔本身是限定M的最⼤量是10000。 runtime/debug包中的SetMaxThreads函数来设置。 有⼀个M阻塞,会创建⼀个新的M。 如果有M空闲,那么就会回收或者睡眠。
make
用于创建和初始化引用类型(如 slice、map 和 channel),而 new
用于创建指针类型的值。make
返回的是所创建类型的引用,而 new
返回的是对应类型的指针。make
接收的参数是类型和一些可选的长度或容量等参数,具体取决于所创建的类型。而 new
只接收一个参数,即所要创建类型的指针。make
创建的引用类型会进行初始化,并返回一个可用的、已分配内存的对象。而 new
创建的指针类型只是返回一个对应类型的指针,并不会进行初始化。Go语言运行时依靠细微的对象切割、极致的多级缓存、精准的位图管理实现了对内存的精细化管理。 将对象分为微小对象、小对象、大对象,使用三级管理结构mcache、mcentral、mheap用于管理、缓存加速span对象的访问和分配,使用精准的位图管理已分配的和未分配的对象及对象的大小。 Go语言运行时依靠细微的对象切割、极致的多级缓存、精准的位图管理实现了对内存的精细化管理以及快速的内存访问,同时减少了内存的碎片。
Go 将内存分成了67个级别的span,特殊的0级特殊大对象大小是不固定的。
当具体的对象需要分配内存时,并不是直接分配span,而是分配不同级别的span中的元素。因此span的级别不是以每个span的大小为依据,而是以span中元素的大小为依据的。
Span等级 | 元素大小(字节) | Span大小(字节) | 元素个数 |
---|---|---|---|
1 | 8 | 8192 | 1024 |
2 | 16 | 8192 | 512 |
3 | 32 | 8192 | 256 |
4 | 48 | 8192 | 170 |
65 | 64 | 8192 | 128 |
… | … | … | … |
65 | 28672 | 57344 | 2 |
66 | 32768 | 32768 | 1 |
第1级span拥有的元素个数为8192/8=1024。每个span的大小和span中元素的个数都不是固定的,例如第65级span的大小为57344字节,每个元素的大小为28672字节,元素个数为2。span的大小虽然不固定,但其是8KB或更大的连续内存区域。 每个具体的对象在分配时都需要对齐到指定的大小,假如我们分配17字节的对象,会对应分配到比17字节大并最接近它的元素级别,即第3级,这导致最终分配了32字节。因此,这种分配方式会不可避免地带来内存的浪费。
为了方便对Span进行管理,加速Span对象访问、分配。分别为mcache、mcentral、mheap。 TCMalloc内存分配算法的思想: 每个逻辑处理器P都存储了一个本地span缓存,称作mcache。如果协程需要内存可以直接从mcache中获取,由于在同一时间只有一个协程运行在逻辑处理器P上,所以中间不需要加锁。mcache包含所有大小规格的mspan,但是每种规格大小只包含一个。除class0外,mcache的span都来自mcentral。
mcentral 所有逻辑处理器P共享的。
对象收集所有给定规格大小的span。每个mcentral都包含两个mspan的链表:empty mspanList表示没有空闲对象或span已经被mcache缓存的span链表,nonempty mspanList表示有空闲对象的span链表。(为了的分配Mspan到Mcache中)
mheap 每个级别的span都会有一个mcentral用于管理span链表(0级除外),其实 都是一个数组,由Mheap管理 作用: 不只是管理central,大对象也会直接通过mheap进行分配。
mheap实现了对虚拟内存线性地址空间的精准管理,建立了span与具体线性地址空间的联系,保存了分配的位图信息,是管理内存的最核心单元。堆区的内存被分成了HeapArea大小进行管理。对Heap进行的操作必须全局加锁,而mcache、mcentral可以被看作某种形式的缓存。
Go 根据对象大小,将堆内存分成了 HeapArea->chunk->span->page 4种内存块进行管理。不同的内存块用于不同的场景,便于高效地对内存进行管理。
Golang内存分配和TCMalloc差不多,都是把内存提前划分成不同大小的块,其核心思想是把内存分为多级管理,从而降低锁的粒度。先了解下内存管理每一级的概念: mspan mspan跟tcmalloc中的span相似,它是golang内存管理中的基本单位,也是由页组成的,每个页大小为8KB,与tcmalloc中span组成的默认基本内存单位页大小相同。mspan里面按照8*2n大小(8b,16b,32b … ),每一个mspan又分为多个object。
mcache mcache跟tcmalloc中的ThreadCache相似,ThreadCache为每个线程的cache,同理,mcache可以为golang中每个Processor提供内存cache使用,每一个mcache的组成单位也是mspan。
mcentral mcentral跟tcmalloc中的CentralCache相似,当mcache中空间不够用,可以向mcentral申请内存。可以理解为mcentral为mcache的一个“缓存库”,供mcaceh使用。它的内存组成单位也是mspan。mcentral里有两个双向链表,一个链表表示还有空闲的mspan待分配,一个表示链表里的mspan都被分配了。
mheap mheap跟tcmalloc中的PageHeap相似,负责大内存的分配。当mcentral内存不够时,可以向mheap申请。那mheap没有内存资源呢?跟tcmalloc一样,向OS操作系统申请。还有,大于32KB的内存,也是直接向mheap申请。
golang 分配内存具体过程如下:
1.介绍内存分配机制
GO语言内存管理子系统主要由两部分组成:内存分配器和垃圾回收器(gc)。内存分配器主要解决小对象的分配管理和多线程的内存分配问题。什么是小对象呢?小于等于32k的对象就是小对象,其它都是大对象。小对象的内存分配是通过一级一级的缓存来实现的,目的就是为了提升内存分配释放的速度以及避免内存碎片等问题
2.介绍MCentral
所有线程共享的组件,不是独占的,因此需要加锁操作。它其实也是一个缓存,cache的一个上游用户,但缓存的不是小对象内存块,而是一组一组的内存page(一个page4K)。从图2可以看出,在heap结构里,使用了一个0到n的数组来存储了一批central,并不是只有一个central对象。从上面结构定义可以知道这个数组长度位61个元素,也就是说heap里其实是维护了61个central,这61个central对应了cache中的list数组,也就是每一个sizeclass就有一个central。所以,在cache中申请内存时,如果在某个sizeclass的内存链表上找不到空闲内存,那么cache就会向对应的sizeclass的central获取一批内存块。注意,这里central数组的定义里面使用填充字节,这是因为多线程会并发访问不同central避免false sharing。
3.介绍mcache
每个线程都有一个cache,用来存放小对象。由于每个线程都有cache,所以获取空闲内存是不用加锁的。cache层的主要目的就是提高小内存的频繁分配释放速度。 我们在写程序的时候,其实绝大多数的内存申请都是小于32k的,属于小对象,因此这样的内存分配全部走本地cache,不用向操作系统申请显然是非常高效的
4.阐述二者区别
mcentral与mcache有一个明显区别,就是有锁存在,由于mcentral是公共资源,会有多个mcache向它申请mspan,因此必须加锁,另外,mcentral与mcache不同,由于P绑定了很多Goroutine,在P上会处理不同大小的对象,mcache就需要包含各种规格的mspan,但mcentral不同,同一个mcentral只负责一种规格的mspan就够了。
Go 的内存分配借鉴了 Google 的 TCMalloc 分配算法,其核心思想是内存池 + 多级对象管理。内存池主要是预先分配内存,减少向系统申请的频率;多级对象有:mheap、mspan、arenas、mcentral、mcache。它们以 mspan 作为基本分配单位。具体的分配逻辑如下: 当要分配大于 32K 的对象时,从 mheap 分配。 当要分配的对象小于等于 32K 大于 16B 时,从 P 上的 mcache 分配,如果 mcache 没有内存,则从 mcentral 获取,如果 mcentral 也没有,则向 mheap 申请,如果 mheap 也没有,则从操作系统申请内存。 当要分配的对象小于等于 16B 时,从 mcache 上的微型分配器上分配。
每次内存分配时都会检查当前内存分配量是否已达到阀值,如果达到阀值则立即启动GC。
阀值 = 上次GC内存分配量 * 内存增长率
内存增长率由环境变量GOGC
控制,默认为100,即每当内存扩大一倍时启动GC
默认情况下,最长2分钟触发一次GC,这个间隔在src/runtime/proc.go:forcegcperiod
变量中被声明:
var forcegcperiod int64 = 2 * 60 * 1e9
程序代码中也可以使用runtime.GC()
来手动触发GC。这主要用于GC性能测试和统计。
三色标记法+混合写屏障
标记清除: 此算法主要有两个主要的步骤:
标记(Mark phase)
清除(Sweep phase)
第一步,找出不可达的对象,然后做上标记。 第二步,回收标记好的对象。
操作非常简单,但是有一点需要额外注意:mark and sweep算法在执行的时候,需要程序暂停!即 stop the world。 也就是说,这段时间程序会卡在哪儿。故中文翻译成 卡顿.
标记-清扫(Mark And Sweep)算法存在什么问题? 标记-清扫(Mark And Sweep)算法这种算法虽然非常的简单,但是还存在一些问题:
STW,stop the world;让程序暂停,程序出现卡顿。
标记需要扫描整个heap
清除数据会产生heap碎片 这里面最重要的问题就是:mark-and-sweep 算法会暂停整个程序。
三色并发标记法: 首先:程序创建的对象都标记为白色。 gc开始:扫描所有可到达的对象,标记为灰色 从灰色对象中找到其引用对象标记为灰色,把灰色对象本身标记为黑色 监视对象中的内存修改,并持续上一步的操作,直到灰色标记的对象不存在 此时,gc回收白色对象 最后,将所有黑色对象变为白色,并重复以上所有过程。
混合写屏障: 注意: 当gc进行中时,新创建一个对象. 按照三色标记法的步骤,对象会被标记为白色,这样新生成的对象最后会被清除掉,这样会影响程序逻辑. golang引入写屏障机制.可以监控对象的内存修改,并对对象进行重新标记. gc一旦开始,无论是创建对象还是对象的引用改变,都会先变为灰色。
goalng1.8的GC采用三色标记法+混合写屏障
三色标记法:将所有对象分为三类,白色、黑色与灰色。
白色:暂无对象引用的潜在垃圾,其内存可能会被垃圾回收器回收
黑色:表示活跃的对象
灰色:黑色与白色的中间状态
三色标记算法分五步进行。
屏障机制分为插入屏障和删除屏障,插入屏障实现的是强三色不变式,删除屏障则实现了弱三色不变式。值得注意的是为了保证栈的运行效率,屏障只对堆上的内存对象启用,栈上的内存会在GC结束后启用STW重新扫描。
插入屏障:对象被引用时触发的机制,当白色对象被黑色对象引用时,白色对象被标记为灰色(栈上对象无插入屏障)。
C
语言这种较为传统的语言通过malloc
和free
手动向操作系统申请和释放内存,这种自由管理内存的方式给予程序员极大的自由度,但是也相应地提高了对程序员的要求。C
语言的内存分配和回收方式主要包括三种:
malloc
申请,free
释放C
、C++
和Rust
等较早的语言采用的是手动垃圾回收,需要程序员通过向操作系统申请和释放内存来手动管理内存,程序员极容易忘记释放自己申请的内存,对于一个长期运行的程序往往是一个致命的缺点。
就是将 对象的内存周期划分为几块,按照每块的情况采取不同的垃圾回收算法。一般是把Java堆分为新生代和老年代。年轻代:年轻代用来存放新近创建的对象,年轻代中存在的对象是死亡非常快的。存在朝生夕死的情况。 老年代:老年代中存放的对象是存活了很久的对象。 垃圾回收算法分为三种,分别为标记-清除算法,复制算法,标记-整理算法。
标记-清除算法:标记无用对象,然后对其进行清除回收。 复制算法:将内存区域划分为大小相等的两部分,每次只使用一部分,当该部分用完后将其存活的对象移至另一部分,并把该部分内存全部清除。 标记-整理算法:标记无用对象,让所有存活的对象都向内存一端移动,然后清除掉存活对象边界外的内存区域。
Golang 的逃逸分析,是指编译器根据代码的特征和生命周期,自动的把变量分配到堆或者是栈上面。Go 在编译阶段确立逃逸,并不是在运行时。可以使用 -gcflags="-m"
参数来查看逃逸分析的详细信息,包括哪些变量逃逸到堆上。
栈( stack)是系统自动分配空间的,例如我们定义一个 char a;系统会自动在栈上为其开辟空间。而堆(heap)则是程序员根据需要自己申请的空间,例如 malloc(10);开辟十个字节的空间。栈在内存中是从高地址向下分配的,并且连续的,遵循先进后出原则。系统在分配的时候已经确定好了栈的大小空间。栈上面的空间是自动回收的,所以栈上面的数据的生命周期在函数结束后,就被释放掉了。堆分配是从低地址向高地址分配的,每次分配的内存大小可能不一致,导致了空间是不连续的,这也产生内存碎片的原因。由于是程序分配,所以效率相对慢些。而堆上的数据只要程序员不释放空间,就一直可以访问到,不过缺点是一旦忘记释放会造成内存泄露。
每当函数中申请新的对象,编译器会根据该对象是否被函数外部引用来决定是否逃逸:
注意,对于函数外部没有引用的对象,也有可能放到堆中,比如内存过大超过栈的存储能力。
func(函数类型)数据类型;interface{} 数据类型 ;指针类型
[]interface{}
数据类型,通过[]
赋值必定会出现逃逸。map[string]interface{}
类型尝试通过赋值,必定会出现逃逸。map[interface{}]interface{}
类型尝试通过赋值,会导致key和value的赋值,出现逃逸。map[string][]string
数据类型,赋值会发生[]string
发生逃逸。[]*int
数据类型,赋值的右值会发生逃逸现象。func(*int)
函数类型,进行函数赋值,会使传递的形参出现逃逸现象。func([]string)
: 函数类型,进行[]string{"value"}
赋值,会使传递的参数出现逃逸现象。chan []string
数据类型,想当前channel中传输[]string{"value"}
会发生逃逸现象。例如如果需要把数字转换成字符串,使用 strconv.Itoa() 比 fmt.Sprintf() 要快一倍左右。如果需要把数字转换成字符串,使用 strconv.Itoa() 比 fmt.Sprintf() 要快一倍左右。
go 内存分配核心思想就是把内存分为多级管理,从而降低锁的粒度。它将可用的堆内存采用二级分配的方式进行管理:每个线程都会自行维护一个独立的内存池,进行内存分配时优先从该内存池中分配,当内存池不足时才会向全局内存池申请,以避免不同线程对全局内存池的频繁竞争。
tcmalloc tcmalloc 是google开发的内存分配算法库,最开始它是作为google的一个性能工具库 perftools 的一部分。TCMalloc是用来替代传统的malloc内存分配函数。它有减少内存碎片,适用于多核,更好的并行性支持等特性。 TC就是Thread Cache两英文的简写。它提供了很多优化,如: 1.TCMalloc用固定大小的page(页)来执行内存获取、分配等操作。这个特性跟Linux物理内存页的划分是不是有同样的道理。 2.TCMalloc用固定大小的对象,比如8KB,16KB 等用于特定大小对象的内存分配,这对于内存获取或释放等操作都带来了简化的作用。 3.TCMalloc还利用缓存常用对象来提高获取内存的速度。 4.TCMalloc还可以基于每个线程或者每个CPU来设置缓存大小,这是默认设置。 5.TCMalloc基于每个线程独立设置缓存分配策略,减少了多线程之间锁的竞争。
Go中的内存分类并不像TCMalloc那样分成小、中、大对象,但是它的小对象里又细分了一个Tiny对象,Tiny对象指大小在1Byte到16Byte之间并且不包含指针的对象。小对象和大对象只用大小划定,无其他区分。 Go内存管理与tcmalloc最大的不同在于,它提供了逃逸分析和垃圾回收机制。
Go 语言有两部分内存空间:栈内存和堆内存。栈内存由编译器自动分配和释放,函数调用的参数、返回值以及局部变量大都会被分配到栈上。堆内存的生命周期比栈内存要长,如果函数返回的值还会在其他地方使用,那么这个值就会被编译器自动分配到堆上。堆内存相比栈内存来说,不能自动被编译器释放,只能通过垃圾回收器才能释放,所以栈内存效率会很高。
虚拟内存就是说,让物理内存扩充成更⼤的逻辑内存,从⽽让程序获得更多的可⽤内存。虚拟内存使⽤部分加载的
技术,让⼀个进程或者资源的某些⻚⾯加载进内存,从⽽能够加载更多的进程,甚⾄能加载⽐内存⼤的进程,这样
看起来好像内存变⼤了,这部分内存其实包含了磁盘或者硬盘,并且就叫做虚拟内存。
recflect是golang用来检测存储在接口变量内部(值value;类型concrete type) pair对的一种机制。它提供了两种类型(或者说两个方法)让我们可以很容易的访问接口变量内容,分别是reflect.ValueOf() 和 reflect.TypeOf()。
type Once struct {
done unit32
m Mutex
}
他们分别为标记是否已经执行过的标志(done),以及执行时所用的互斥锁(m) 除了结构体外,sync.Once还包括了一个公开的方法Do:
func (o *Once) Do(f func()) {
if atomic.LoadUint32(&o.done) == 0 {
o.doSlow(f)
}
}
Once.Do方法的实现非常简单,通过atomic.LoadUint32获取Once实例的done属性值。 若done值为0时,表示函数f未被调用过或正运行中且未结束,则将调用doSlow方法; 若done值为1时,表示函数f已经调用且完成,则直接返回。 这里使用了原子操作方法atomic.LoadUint32而不是直接将o.done进行比较,也是为了避免并发状态下错误地判断执行状态,产生不必要的锁操作带来的时间开销。
func (o *Once) doSlow(f func()) {
o.m.Lock()
defer o.m.Unlock()
if o.done == 0 {
defer atomic.StoreUint32(&o.done, 1)
f()
}
}
Once.doSlow方法的实现使用了传统的互斥锁Mutex操作,在执行时即调用o.m.Lock方法获得锁,然后再继续判断是否已经完成并调用f函数。 可以看到,在获得锁后还需要对o.done的值进行一次判断,避免了f函数被重复调用。 最后,在退出doSlow方法时还需要对获取的锁进行释放,若进入到f函数的调用则需要更改o.done属性值。
Context 是一个接口,定义了 4 个方法,它们都是幂等的。也就是说连续多次调用同一个方法,得到的结果都是相同的。
context 监听是否有 IO 操作,开始从当前连接中读取网络请求,每当读取到一个请求则会将该cancelCtx传入,用以传递取消信号,可发送取消信号,取消所有进行中的网络请求。
Go中天然的支持并发,Go允许使用go语句开启一个新的运行期线程,即 goroutine,以一个不同的、新创建的goroutine来执行一个函数。同一个程序中的所有goroutine共享同一个地址空间。 Goroutine非常轻量,除了为之分配的栈空间,其所占用的内存空间微乎其微。并且其栈空间在开始时非常小,之后随着堆存储空间的按需分配或释放而变化。内部实现上,goroutine会在多个操作系统线程上多路复用。如果一个goroutine阻塞了一个操作系统线程,例如:等待输入,这个线程上的其他goroutine就会迁移到其他线程,这样能继续运行。开发者并不需要关心/担心这些细节。 Go语言的并发机制运用起来非常简便,在启动并发的方式上直接添加了语言级的关键字就可以实现,和其他编程语言相比更加轻量。
channel:
被单独创建并且可以在进程之间传递,它的通信模式类似于 boss-worker 模式的,一个实体通过将消息发送到 channel 中,然后又监听这个 channel 的实体处理,两个实体之间是匿名的,这个就实现实体中间的解耦,在实现原理上其实是一个阻塞的消息队列。 3) 调度器:
goroutine 中提供了调度器,在调度器加入了steal working 算法 ,goroutine 是可以被异步抢占,因此没有函数调用的进程不再对调度器造成死锁或造成垃圾回收的大幅变慢。并且 go 对网络IO库进行了封装,屏蔽了复杂的细节,对外提供统一的语法关键字支持,简化了并发程序编写的成本。golang控制并发有三种经典的方式,一种是通过channel通知实现并发控制 一种是WaitGroup,另外一种就是Context。
Go语言中实现了两种并发模型,一种是我们熟悉的线程与锁的并发模型,它主要依赖于共享内存实现的。程序的正确运行很大程度依赖于开发人员的能力和技巧,程序在出错时不易排查。另一种就是CSP并发模型,它使用通信的手段来共享内存。CSP中的并发实体是独立的,它们之间没有共享的内存空间,它们之间的数据交换通过通道实现的
CSP并发模型:
Go实现了两种并发模式。第一种:多线程共享内存。第二种:通过通信来共享内存(CSP)
CSP并发模型是Go语言特有的并发模型,也是Go语言官方所推荐的并发模型。
Go的CSP并发模型,是由Go语言中的goroutine
与channel
共同来实现的。
go
来创建goroutine。将关键字go
放到需要调用的函数前,在相同地址空间调用运行这个函数,该函数在执行的时候会创建一个独立的线程去执行,这个线程就是Go语言中的goroutine。线程模型:
一对一模型(1:1)
将一个用户级线程映射到一个内核线程,每一个线程由内核调度器独立调度,线程之间互不影响
优点:在多核处理器的条件下,实现了真正的并行。
缺点:为每一个用户级线程建立一个内核线程,开销大,浪费资源。
多对一模型(M:1)
将多个用户级线程映射到一个内核线程。
优点:线程上下文切换发生在用户空间。
缺点:只有一个处理器被应用,在多处理环境下是不可以被接受的,实现了并发,不能解决并行问题。
多对多模型(M:N)
多个用户级线程运行在多个内核线程上,这使得大部分的线程上下文切换都发生在用户空间,而多个内核线程又能充分利用处理器资源
Go1.7加入到标准库,在于控制goroutine的生命周期。当一个计算任务被goroutine承接了之后,由于某种原因,我们希望中止这个goroutine的计算任务,那么就用得到这个Context了。 包含CancelContext,TimeoutContext,DeadLineContext,ValueContext
场景一:RPC调用 在主goroutine上有4个RPC,RPC2/3/4是并行请求的,我们这里希望在RPC2请求失败之后,直接返回错误,并且让RPC3/4停止继续计算。这个时候,就使用的到Context。
场景二:PipeLine runSimplePipeline的流水线工人有三个,lineListSource负责将参数一个个分割进行传输,lineParser负责将字符串处理成int64,sink根据具体的值判断这个数据是否可用。他们所有的返回值基本上都有两个chan,一个用于传递数据,一个用于传递错误。(<-chan string, <-chan error)输入基本上也都有两个值,一个是Context,用于传声控制的,一个是(in <-chan)输入产品的。
场景三:超时请求 我们发送RPC请求的时候,往往希望对这个请求进行一个超时的限制。当一个RPC请求超过10s的请求,自动断开。当然我们使用CancelContext,也能实现这个功能(开启一个新的goroutine,这个goroutine拿着cancel函数,当时间到了,就调用cancel函数)。鉴于这个需求是非常常见的,context包也实现了这个需求:timerCtx。具体实例化的方法是 WithDeadline 和 WithTimeout。具体的timerCtx里面的逻辑也就是通过time.AfterFunc来调用ctx.cancel的。
场景四:HTTP服务器的request互相传递数据 context还提供了valueCtx的数据结构。这个valueCtx最经常使用的场景就是在一个http服务器中,在request中传递一个特定值,比如有一个中间件,做cookie验证,然后把验证后的用户名存放在request中。我们可以看到,官方的request里面是包含了Context的,并且提供了WithContext的方法进行context的替换。
Go的设计思想就是, 不要通过共享内存来通信,而是通过通信来共享内存,前者就是传统的加锁,后者就是Channel。也就是说,设计Channel的主要目 的就是在多任务间传递数据的,本身就是安全的。 看源码就知道channel内部维护了一个互斥锁,来保证线程安全,channel底层实现出队入队时也加锁。
共享内存会涉及到多个线程同时访问修改数据的情况,为了保证数据的安全性,那就会加锁,加锁会让并行变为串行,cpu此时也会忙于线程抢锁。另外使用过多的锁,容易使得程序的代码逻辑坚涩难懂,并且容易使程序死锁,死锁了以后排查问题相当困难,特别是很多锁同时存在的时候。
在这种情况下,不如换一种方式,把数据复制一份,每个线程有自己的,只要一个线程干完一件事其他线程不用去抢锁了,这就是一种通信方式,把共享的以通知方式交给线程,实现并发。go语言的channel就保证同一个时间只有一个goroutine能够访问里面的数据,为开发者提供了一种优雅简单的工具,所以go原生的做法就是使用channle来通信,而不是使用共享内存来通信。