赞
踩
参考1:Slice底层实现
参考2:Golang-Slice 内部实现原理解析
扩展的看参考2。
Golang
中,数组是值类型,赋值和函数传参操作都会复制整个数组数据。在数据量非常大时,每次传参都用数组,那么每次数组都要被复制一遍。这样会消耗掉大量的内存。所以函数传参用改为使用数组的指针。注意:数组声明的时候,数组长度也属于类型中的一部分,所以两个数组只有 数组长度 和 类型 完全相同时才能比较、赋值。
代码位置:src/runtime/slice.go
type slice struct {
array unsafe.Pointer
len int
cap int
}
Pointer
是指向一个数组的指针,len
代表当前切片的长度,cap
是当前切片的容量。cap
总是大于等于len
的。判断是否需要扩容:当向切片中追加元素时,如果当前元素个数(长度)超过了底层数组的容量,就需要进行扩容。
go 1.18之前的扩容规则,临界值是1024,小于1024的时候,切片先两倍扩容,如果两倍扩容后的容量还是不够,就直接以切片需要的容量作为容量。
Go
语言会自动回收该底层数组的内存。go1.18之后,临界值换成了256,小于256的时候,切片先两倍扩容,如果两倍扩容后的容量还是不够,就直接以切片需要的容量作为容量。
大于256公式变为(oldCap+3*256)/4这个公式的值随着oldcap的越来越大,从2一直接近1.25,相对于1.18之前可以更平滑的过渡。
注意:
Golang
切片扩容时的值拷贝是深拷贝还是浅拷贝?【回答来自GPT3.5】
在Go语言中,切片的扩容过程涉及值的复制操作。这是浅拷贝的一种情况,因为只复制了切片中的元素本身,而没有复制元素所引用的对象。
具体来说,在切片扩容时,Go语言会创建一个新的底层数组,并将原有切片中的元素逐个复制到新的底层数组中。这里复制的是切片中元素的值,而不是复制元素引用的对象。所以,原切片和新切片会引用不同的底层数组,但它们的元素值可能是相同的,因为是值拷贝。
这也意味着,如果切片中的元素是引用类型(例如,切片、映射或自定义结构体),复制的仅是这些引用值,并没有复制引用指向的实际对象。因此,如果修改原切片或新切片中的引用值指向同一个对象,修改会在两个切片中都反映出来,因为它们共享相同的引用。
切片扩容时的值拷贝示例:
package main
import "fmt"
func main() {
// 创建原始切片
slice1 := []int{1, 2, 3}
// 创建新切片,通过 append 扩容
slice2 := append(slice1, 4)
// 修改原切片中的元素
slice1[0] = 100
fmt.Println("原始切片:", slice1) // 输出 [100 2 3]
fmt.Println("新切片:", slice2) // 输出 [1 2 3 4]
}
注意到,修改原切片slice1
中的元素并不会影响新切片slice2
,这是因为它们指向了不同的底层数组。但是,如果修改切片中的元素是引用类型,比如修改切片中的切片或映射中的值,那么会影响原切片和新切片,因为它们共享相同的引用。
初始化方式:(一般使用make
初始化的时候len
最好设置为0,避免使用时切片前面的数据出现多余的零值数据。)
make([]T,len);
//与
make([]T,len,cap);
问下列初始化后的输出结果:
make([]int,8);
//与
make([]int,0,8);
这两种都是初始化了一个切片,根据初始化方式可知,主要的区别为是否声明了容量。
make([]int,8);
//声明的切片长度是8,在未使用前,这个切片已有8个数据,数值均为0,
//因为8个位置的值均为类型的零值,int型的零值是0,所以输出是8个0.
make([]int,0,8);
//声明的切片长度是0,容量是8,在未使用前,
//这个切片中没有任何数据,只是容量是8,所以输出为空【即没有数据】。
浅拷贝:拷贝的是地址,只是复制指向对象的指针。切片是引用类型数据,默认引用类型数据,全部都是浅拷贝,比如:切片,Map
等。
分析:
slice2 := slice1
slice1
和slice2
指向的都是同一个底层数组,任何一个数组元素被改变,都可能会影响两个切片。slice1
和slice2
指向的都是相同数组,但在触发扩容操作后,二者指向的就不一定是相同的底层数组了,具体可参考上面的slice
扩容规则。深拷贝:拷贝的是数据本身,会创建一个新对象。
copy(slice2, slice1)
新对象和原对象不共享内存,在新建对象的内存中开辟一个新的内存地址,新对象的值修改不会影响原对象值,既然内存地址不同,释放内存地址时,可以分别释放。
当切片的底层数组很大,但切片所取元素数量很小时,底层数组占据的大部分空间都是被浪费的。
比如切片b的底层数组很大,切片a只引用了切片b很小的一部分,只要切片a还在,切片b底层数组就永远不会被回收,这样就造成了内存泄露!
代码示例:
var a []int
func test(b []int) {
a = b[:1] // 和b共用一个底层数组
return
}
解决方法:
不要引用切片b的底层数组,将需要的数据复制到一个新的切片中,这样新切片的底层数组,就和切片b的底层数组无任何关系了。
var a []int
func test(b []int) {
a = make([]int, 1)
copy(a, b[:0])
return
}
切片不是并发安全的,要并发安全,有两种方法:
面试题:切片和map的数据结构并发安全吗?
答:切片的写入和map
的写入一样都是非线程安全的,但是map
有sync.Map{}
,切片只能通过加锁
或channel
方式来实现线程安全的并发写操作。
加锁:适合于对性能要求不高的场景,毕竟锁的粒度太大,这种方式属于通过共享内存来实现通信。
代码示例:
func TestSliceConcurrencySafeByMutex(t *testing.T) {
var lock sync.Mutex //互斥锁
a := make([]int, 0)
var wg sync.WaitGroup
for i := 0; i < 10000; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
lock.Lock()
defer lock.Unlock()
a = append(a, i)
}(i)
}
wg.Wait()
t.Log(len(a))
// equal 10000
}
channel:适合于对性能要求大的场景,channel
就是专用于goroutine
间通信的,这种方式属于通过通信来实现共享内存,而Go的箴言便是:尽量通过通信来实现内存共享,而不是通过共享内存来实现通信
,推荐此方法!
代码示例:
func TestSliceConcurrencySafeByChanel(t *testing.T) {
buffer := make(chan int)
a := make([]int, 0)
// 消费者
go func() {
for v := range buffer {
a = append(a, v)
}
}()
// 生产者
var wg sync.WaitGroup
for i := 0; i < 10000; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
buffer <- i
}(i)
}
wg.Wait()
t.Log(len(a))
// equal 10000
}
参考3:Golang比较两个字符串切片是否相等
方式一:reflect.DeepEqual(s1, s2)方法
func equal( s1 []int , s2 []int ) bool {
return reflect.DeepEqual(s1, s2)
}
说明:reflect.DeepEqual()
接收的是两个interface{}
类型的参数,首先判断两个参数的类型是否相同,然后才会根据类型层层判断。
方式二:循环遍历切片逐个元素进行比较
func equal( s1 []int , s2 []int ) bool {
if len(s1) != len(s2) {
return false
}
for i := 0; i < len(s1); i++ {
if s1[i] != s2[i] {
return false
}
}
return true
}
在Go语言中,append函数用于向切片(slice)追加元素。append的时间复杂度是均摊 O(1) 的,这意味着在大多数情况下,单次append操作的时间复杂度是常数级别的。
append操作的均摊复杂度为O(1),是因为Go在进行append操作时,会使用一种动态扩容的机制。当切片的容量不足以容纳新增元素时,Go会创建一个新的底层数组,并将原始元素复制到新的数组中。这样做的目的是确保在大多数情况下,append的时间复杂度是常数级别的。
具体来说,append操作的均摊时间复杂度为O(1) 意味着执行N次append操作的总时间复杂度为O(N),其中N为元素的总个数。每次append操作的平均时间复杂度是常数级别的,但在某些情况下可能需要进行底层数组的重新分配和复制,导致某次append操作的耗时略高。
需要注意的是,虽然append的均摊时间复杂度是O(1),但在实际编程中,仍然需要注意避免频繁进行append操作,尤其是在循环中,因为每次append都可能触发底层数组的重新分配和复制,影响性能。
参考1:Golang协程详解和应用
Golang
的协程是为了解决多核CPU
利用率问题,Golang
语言层面并不支持多进程或多线程,但是协程更好用,它是一种轻量级的并发执行单元,是Golang
语言提供的一种特性,使得在同一个程序中可以同时执行多个函数或方法,实现高效的并发编程。协程被称为用户态线程,因为不存在CPU
上下文切换问题,所以效率非常高。
两两区分:进程与线程、线程与协程。
进程:
资源分配
的最小单位。线程:
资源调度
的最小单位)。栈空间
和寄存器
,因此CPU切换一个线程的开销远比进程要小很多,同时创建一个线程的开销也比进程要小很多。进程和线程的关系:
协程:
内存占用
创建一个协程的栈内存
消耗默认为2KB。创建一个线程则需要消耗MB级别以上的栈内存
。
创建和销毁
(1)线程创建和销毀都会有巨大的消耗,因为线程是由操作系统管理的,创建和销毁线程涉及到内核的调度和资源分配,是内核级
的,相对较重量级,通常解决的办法就是线程池。
(2)协程因为是由 Go 运行时(runtime)使用了自己的调度器管理的,创建和销毁的消耗非常小,是用户级
。因此在Go语言中可以轻松创建成千上万个协程而不会导致资源耗尽。这种特性使得Go语言在并发编程中非常高效,可以充分利用多核处理器的能力,实现高性能的并发程序。
通信
(1)Go协程之间通过通道(Channel)进行通信,从而实现数据传递和同步操作。
(2)线程之间的通信通常需要使用操作系统提供的同步原语,如锁、信号量等,较为繁琐。
切换
当线程切换时,需要保存各种寄存器,以便将来恢复,而 goroutines 切换只需保存三个寄存器。
一般而言,线程切换会消耗 1000-1500 纳秒,一个纳秒平均可以执行 12-18 条指令。所以由于线程切换,执行指令的条数会减少 12000-18000。协程的切换约为 200ns,相当于 2400-3600 条指令。因此,协程切换成本比线程要小得多。
参考1:线程间到底共享了哪些进程资源
线程的私有信息:
线程的共享信息:线程之间共享除线程上下文信息
中的所有内容,包括栈区、堆区、代码区、数据区。
代码区:进程中的代码区存储的是编译后的可执行机器指令。而这些机器指令是从可执行文件中加载到内存的。
线程之间共享代码区,意味着任何函数都可以被线程执行。
堆区:malloc/new
出来的数据就存放在这个区域。
栈区:线程的上下文信息通常是私有的,但它们并没有严格的隔离机制来保护。因此, 若一个线程能拿到来自另一个线程栈帧上的指针,那么该线程就可以改变另一个线程的栈区。
文件:若线程保存有打开的文件信息,则进程打开的文件也可以被所有的线程使用,这也属于线程间的共享资源。
不可以,因为线程是资源调度的最小单位,一个进程至少要有一个线程来作为主线程。
共享
的内存区域,主要存放对象实例,这说明一个线程在堆上分配的内存可以被其他线程访问和使用,但需要注意对堆内存的访问进行同步控制,以避免竞态条件和内存访问冲突。独享
的内存区域,主要存放各种基本数据类型、对象的引用,一个线程的栈上的数据只能被自己的代码访问,其他线程无法直接访问。参考1:https://zhuanlan.zhihu.com/p/323271088
只看 二、Goroutine调度器的GMP模型的设计思想
往后的即可。
Golang
的协程调度是通过GMP模型
实现的。
P(Processor)在Golang中指的是逻辑处理器。每个P负责调度和管理一组协程的执行。逻辑处理器(P)是协程与操作系统线程(M)的中间层,它允许多个协程在一个操作系统线程(M)上进行并发执行,如果线程想运行协程,必须先获取逻辑处理器(P)。
P与处理器核心(物理处理器)是不同的概念。逻辑处理器(P)的数量默认情况下与处理器核心数相同,但Go运行时可以根据系统的负载情况动态地增加或减少逻辑处理器(P)的数量,以适应程序的并发需求。每个逻辑处理器(P)会从全局的运行队列中获取待执行的协程,并将其映射到一个空闲的操作系统线程(M)上执行。
面试官的回答:协程只是一个虚拟的概念,是Go
语言层面的一个东西。其实就是一段代码,依赖于操作系统来执行的,GMP本质是一个调度的工具,帮我们把程序代码怎么合理的分配到一个线程上的。
在Go
中,线程是最终运行协程实体,调度器的功能是把可运行的协程分配到工作线程上。
Global Queue
):存放等待运行的协程。GOMAXPROCS
(可配置)个。逻辑处理器(P)的数量默认与处理器核心数相同,但Go运行时可以在运行时动态增加或减少逻辑处理器的数量,以适应程序的并发需求。逻辑处理器的数量决定了并行执行的协程数目,当逻辑处理器的数量较多时,Go语言可以更充分地利用多核处理器。协程调度器和操作系统的调度器是通过线程结合起来的,每个线程都代表了1个内核线程,操作系统的调度器负责把内核线程分配到CPU
的核上执行。
逻辑处理器P的数量:默认情况下与物理处理器核心数相同,但Golang
运行时可以根据系统的负载情况动态地增加或减少逻辑处理器(P)的数量,以适应程序的并发需求。
线程M的数量:在Golang
中,M(Machine
)的数量由Golang
运行时(runtime
)根据系统的负载情况和配置参数进行决定。M
是Golang
语言运行时的操作系统线程,负责管理和执行Goroutine
。在运行时,Golang
语言会根据以下因素来确定M的数量:
Golang
运行时会根据当前系统的负载情况来调整M
的数量。如果系统负载较高,可能会增加M
的数量,以充分利用多核处理器的性能。相反,如果系统负载较低,可能会减少M
的数量,以节省资源。GOMAXGCTIME
是一个环境变量,用于控制垃圾回收的时间。Golang
运行时会根据垃圾回收的负载情况来调整M
的数量。垃圾回收是Golang
语言运行时的一个重要机制,它负责回收不再使用的内存。Golang
程序需要处理大量的并发任务,Golang
运行时可能会增加M
的数量以满足性能需求。反之,如果程序并发需求较低,Golang
运行时可能会减少M
的数量以减少资源占用。总的来说,M
的数量是由Golang
运行时动态调整的,目的是根据系统负载和性能需求,充分利用多核处理器的性能,实现高效的并发编程。开发者可以通过GOMAXPROCS
等环境变量来进行一定的调整,但一般情况下不需要手动管理M的数量,Golang
语言运行时会自动处理。
线程M
与逻辑处理器P
的数量没有绝对关系,一个线程M
阻塞,逻辑处理器P
就会去创建或者切换另一个线程M
,所以,即使逻辑处理器P
的默认数量是1,也有可能会创建很多个线程M
出来。
逻辑处理器P和线程M何时会被创建:
逻辑处理器P
何时创建:在确定了逻辑处理器P
的最大数量n后,系统启动时系统会根据这个数量创建n个逻辑处理器P
。线程M
何时创建:没有足够的线程M
来关联处理器P
并运行其中的可运行的Goroutine
。比如所有的线程M
此时都阻塞住了,而处理器P
中还有很多就绪任务,就会去寻找空闲的线程M
,而没有空闲的,就会去创建新的线程M
。参考:Golang高并发编程技巧:深入理解Goroutines的调度策略
Goroutine
的调度策略主要包括三个方面:抢占式调度、协作式调度、Work Stealing。
Golang
的调度器采用的是抢占式调度策略,即任何一个Goroutine
的执行都可能被其他Goroutine
随时中断。这种调度策略的好处是能够合理分配CPU
资源,防止某个Goroutine
长时间独占CPU
而导致其他Goroutine
无法执行。当一个Goroutine
被抢占时,调度器会将其状态保存,并切换到其他可执行的Goroutine
。Golang
的调度器还采用了协作式调度策略。在协作式调度中,Goroutine
会自动放弃CPU
的执行权利,而不是一直占用CPU
。通过在适当的时机主动让出CPU
,在Goroutine
之间合理切换,可以提高整个系统的并发性能。Work Stealing
是Golang
调度器中的一个非常重要的机制。它的核心思想是让处在空闲状态的线程主动“偷取”其他线程的任务来执行,从而实现线程之间的负载均衡。这种机制能够避免某些线程工作过多,而其他线程一直处于空闲状态的情况,进一步提高并发程序的性能。参考1:http://t.zoukankan.com/ExMan-p-12091738.html
计算机资源是有限的,所以Goroutine
肯定也是有限制的,单纯的Goroutine
,一开始每个占用2KB内存,所以这里会受到内存使用量的限制,还有Goroutine
是通过系统线程来执行的,Golang
默认最大的线程数是10000个。可以通过runtime/debug
中的SetMaxThreads
函数,设置M的最大数量。但要注意线程和Goroutine
不是一一对应关系,理论上内存足够大,而且Goroutine
不是计算密集型的话,可以开启无限个Goroutine
。
RWMutex
)channel
)协程是用户态。
来自GPT3.5的回答
func worker(stopCh <-chan struct{}) {
for {
select {
case <-stopCh:
// 收到停止信号,安全退出
return
default:
// 正常处理任务
}
}
}
func main() {
stopCh := make(chan struct{})
go worker(stopCh)
// 停止并退出协程
// 发送停止信号到通道
close(stopCh)
// 或者使用: stopCh <- struct{}{}
}
import (
"context"
)
func worker(ctx context.Context) {
for {
select {
case <-ctx.Done():
// 收到取消信号,安全退出
return
default:
// 正常处理任务
}
}
}
func main() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel() // 释放资源
go worker(ctx)
// 停止并退出协程
cancel()
}
在这个例子中,我们使用context.WithCancel创建一个Context,并在main函数中调用cancel函数来发送取消信号给worker协程。
步骤:
package main
import (
"fmt"
"sync"
"time"
)
func worker(id int, wg *sync.WaitGroup) {
defer wg.Done()
fmt.Printf("Worker %d started\n", id)
time.Sleep(1 * time.Second) // 模拟耗时的工作
fmt.Printf("Worker %d finished\n", id)
}
func main() {
const numWorkers = 3
var wg sync.WaitGroup
wg.Add(numWorkers)
for w := 1; w <= numWorkers; w++ {
go worker(w, &wg)
}
wg.Wait()
fmt.Println("All workers have finished. Continue with the main process.")
}
在上面的例子中,我们使用了sync.WaitGroup来控制并发数。首先,我们调用wg.Add(numWorkers)来设置等待组的计数器为numWorkers,表示有numWorkers个协程需要等待。然后,在每个协程的处理函数中,我们使用defer wg.Done()在函数执行完成时通知等待组计数器减1。最后,我们调用wg.Wait()来阻塞主线程,直到所有协程完成。
通过这种方式,我们可以控制并发数为numWorkers,确保所有协程都完成后再继续执行主线程的后续逻辑。这是一种简单而有效的方式来管理并发协程。
每个Goroutine的栈内存大小默认为2KB,这在大多数情况下是足够的。但是,栈内存的大小可以通过Golang的runtime.Stack函数来查看或者通过runtime.GOMAXPROCS函数设置。
除了栈内存,每个Goroutine还会占用少量的额外内存,用于Goroutine的管理和调度。这些开销相对较小,通常在几KB的范围内。
defer+recover来捕获并处理异常。
来自GPT3.5的回答:
在Go语言中,协程(Goroutine)的异常不能被其他协程捕获或控制,而是会导致整个程序崩溃。这是Go语言设计的一种原则,即一个Goroutine的异常不应该影响其他的Goroutines,而应该由每个Goroutine自己负责处理异常。
当一个Goroutine发生了未被捕获的异常(例如数组越界、空指针等),Go语言运行时会把该异常视为致命错误,并会终止整个程序的运行。这是为了确保程序在出现严重错误时能够快速停止,避免继续运行可能导致更严重问题的状态。
虽然无法直接控制协程抛出的异常,但我们可以在每个Goroutine中使用recover函数来捕获并处理异常。recover函数只能在defer语句中调用,用于捕获panic引发的异常。通过在Goroutine的处理函数中使用defer和recover,可以捕获异常并在出现异常时采取一些处理措施,从而避免整个程序崩溃。
package main
import (
"fmt"
)
func worker() {
defer func() {
if r := recover(); r != nil {
fmt.Println("Recovered from:", r)
}
}()
// 模拟发生异常
var arr [5]int
fmt.Println(arr[10]) // 会导致数组越界异常
}
func main() {
go worker()
// 主协程休眠等待一段时间
// 这是为了让Goroutine有足够的时间执行
// 在实际应用中,可能需要采用更好的方式来等待Goroutine的执行
select {}
}
在上面的例子中,我们在worker协程中使用了defer和recover,用于捕获可能发生的异常。当数组越界异常发生时,recover会捕获该异常,并在控制台打印异常信息,但程序不会崩溃,而是继续执行。
需要注意的是,即使在一个协程中使用了recover捕获了异常,其他的协程仍然不受影响。异常只会影响当前的协程,而不会影响其他的协程。因此,在Golang中,建议每个协程都独立处理可能的异常,确保程序在出现异常时能够优雅地处理错误。
同 2.3 Golang最多能启动多少个协程
因素:计算机内存
和线程数
。
并发处理。有缓冲的channel可以控制并发数目,从而实现多线程的并发处理。
答:通过channel,将错误信息放入channel中,父级协程监听该channel就能获取到子级的错误信息了。
使用sync.WaitGroup来实现监听多个协程同步返回的情况。
在Go语言中,调用time.Sleep会使当前的goroutine进入休眠状态,让出CPU的执行权给其他可运行的goroutine。当一个goroutine调用time.Sleep时,它会被放入等待队列,等待指定的时间过去后再被重新放入可运行队列,准备再次执行。
Go语言的调度器采用抢占式调度,即在每个goroutine执行的适当点上,调度器都有机会检查是否有更高优先级的goroutine可以运行。因此,当一个goroutine调用time.Sleep时,它就放弃了CPU的执行权,调度器会在这个时候选择其他可运行的goroutine来执行。
具体的执行流程可以描述如下:
这种抢占式调度的机制使得在休眠期间,其他goroutine有机会继续执行,提高了并发程序的效率。需要注意的是,time.Sleep会导致当前goroutine休眠,但不会阻塞整个线程,因此其他goroutine仍然可以在同一个线程上执行。
首先要记住的是 Go语言使用的是基于标记-清除(Mark-Sweep)算法
改进后的三色标记法
来进行内存垃圾回收。
垃圾回收这块整理起来比较繁琐,特别是三色标记法这块,参考和结合的地方较多,所以在具体内容附近加了很多参考的链接,可以复制查找出处。
参考1:浅析 Golang 垃圾回收机制
参考2:Golang 垃圾回收
参考3:Golang 垃圾回收机制详解
参考4:Golang-垃圾回收原理解析
参考5:图解Golang垃圾回收机制!
常见的垃圾回收算法:
标记 — 清除法
三色标记法
按照对象生命周期长短划分不同的代空间,生命周期长的放入老年代,短的放入新生代,不同代有不同的回收算法和回收频率。浅析 Golang 垃圾回收机制
这样划分,堆就分成了Young和Old两个分区,因此GC也分为新生代GC和老年代GC。Golang-垃圾回收原理解析
对象的分配策略:Golang-垃圾回收原理解析
代表语言: Java
优点: 回收性能好。
缺点: 算法复杂。
浅析 Golang 垃圾回收机制
引用计数法会为每个对象维护一个计数器,当该对象被其他对象引用时,该引用计数加1,当引用该对象的对象销毁(引用失效)时减1,当引用计数为0后即可回收对象。浅析 Golang 垃圾回收机制
代表语言: Python、PHP、Swift。
优点: 对象回收快,因为引用计数为0则立即回收,不会出现内存耗尽或达到某个阈值时才回收。
缺点:
若是A引用了B,B也引用了A,形成循环引用,当A和B的引用计数更新到只剩彼此的相互引用时,引用计数便无法更新到0,也就不能回收对应的内存了
)Golang 垃圾回收机制详解参考:Golang-垃圾回收原理解析
主要分为标记和复制两个步骤:
优点:
缺点:
所以 标记 — 清除法
就是从根变量开始遍历所有引用的对象,然后对引用的对象进行标记,将没有被标记的进行回收。浅析 Golang垃圾回收机制
代表语言: Golang
(三色标记法)
优点:解决了引用计数的缺点。
缺点:需要STW(Stop The World)
,即暂时停掉程序运行。
算法分两个部分: 标记(mark)
和清除(sweep)
。标记阶段表明所有已使用的引用对象,清除阶段将未使用的对象回收。
具体步骤: 图解Golang垃圾回收机制!
STW
(Stop The World
即暂停程序业务逻辑),然后从main
函数开始找到不可达的内存占用和可达的内存占用。STW
,让程序继续运行,循环该过程直到main
生命周期结束。标记出所有可达对象,然后将可达对象移动到空间的另外一段,最后清理掉边界以外的内存。
优点:
缺点:
STW
时间比标记清除算法高。三色标记法只是为了叙述方便而抽象出来的一种说法,实际上的对象是没有三色之分的 浅析 Golang 垃圾回收机制。前面的标记-x类算法都有一个问题,那就是STW
(即gc时暂停整个应用程序),三色标记法是对标记阶段进行改进的算法,目的是在不暂停程序的情况下即可完成对象的可达性分析,垃圾回收线程将所有对象分为三类:Golang-垃圾回收原理解析
优点: 不需要STW
。Golang-垃圾回收原理解析
缺点: Golang-垃圾回收原理解析
三色标记算法属于增量式GC算法,回收器首先将所有对象着色成白色,然后从gc root出发,逐步把所有可达的对象变成灰色再到黑色,最终所有的白色对象都是不可达对象。Golang-垃圾回收原理解析
具体流程图:浅析 Golang 垃圾回收机制
具体流程文字描述:Golang-垃圾回收原理解析
Golang
语言的垃圾回收器(Garbage Collector,GC
)会导致程序的停顿,这种停顿被称为Stop-The-World(STW)
。在Golang
语言中,有两个主要的停顿事件:一是用于标记对象的停顿,二是用于清理和回收不再使用的对象的停顿。
STW
停顿。在Golang
语言中,标记阶段是由GCTime
触发的,默认情况下,GCTime
为100ms
,表示每隔100ms
就会进行一次标记阶段的垃圾回收。可以通过设置环境变量GOGC
来调整GCTime
的值。STW
停顿。在清理阶段,回收器会扫描和清理被标记为不再使用的对象,并将它们的内存释放回堆。清理阶段的时间通常较短。总的来说,垃圾回收的STW
停顿主要发生在标记阶段和清理阶段。标记阶段的频率由GCTime
控制,而清理阶段在标记阶段之后立即进行。Golang
语言的垃圾回收器的设计目标之一是尽量减小STW
时间,以提高程序的响应性。因此,Golang
的垃圾回收器采用了一些技术手段,如并发标记(Concurrent Marking
)和并发清理(Concurrent Sweeping
),以减小STW
的影响。
这种方法看似很好,但是将GC
和程序会放一起执行,会因为CPU
的调度可能会导致被引用的对象会被垃圾回收掉,从而出现错误。图解Golang垃圾回收机制!
分析问题的根源所在,主要是因为程序在运行过程中出现了下面俩种情况:图解Golang垃圾回收机制!
因此在此基础上拓展出了两种方法,强三色不变式和弱三色不变式。图解Golang垃圾回收机制!
参考:图解Golang垃圾回收机制!
为了实现这两种不变式的设计思想,从而引出了屏障机制,即在程序的执行过程中加一个判断机制,满足判断机制则执行回调函数。
屏障机制分为插入屏障
和删除屏障
,插入屏障实现的是强三色不变式
,删除屏障则实现了弱三色不变式
。需要注意的是为了保证栈
的运行效率,屏障只对堆
上的内存对象启用,栈
上的内存会在GC
结束后启用STW
重新扫描。
插入写屏障:对象被引用时触发的机制,当白色对象被黑色对象引用时,白色对象被标记为灰色(栈上对象无插入屏障)。
缺点:如果灰色对象在栈上新创建了一个新对象,由于栈没有屏障机制,所以新对象仍为白色节点会被回收。
删除写屏障:对象被删除时触发的机制。如果灰色对象引用的白色对象被删除时,那么白色对象会被标记为灰色。
缺点:这种做法回收精度较低,一个对象即使被删除仍可以活过这一轮再下一轮被回收。同样也存在对栈的二次扫描影响程序的效率。
参考:图解Golang垃圾回收机制!
但是插入写屏障
和删除写屏障
在结束时需要STW来重新扫描栈,带来了性能瓶颈,所以Go在1.8引入了混合写屏障
的方式实现了弱三色不变式的设计方式,混合写屏障分下面四步。
注意:混合写屏障也仅是在堆上启动。
参考:Golang-垃圾回收原理解析
前面提到的传统GC算法都会STW,这存在两个严重的弊端:
三色标记法结合写屏障技术使得GC避免了STW,因此后面的增量式GC和并发式GC都是基于三色标记和写屏障技术的改进。
增量式垃圾回收:可以分摊GC时间,避免程序长时间暂停。
存在的问题:内存屏障技术,需要额外时间开销,并且由于内存屏障技术的保守性,一些垃圾对象不会被回收,会增加一轮gc的总时长。
并发垃圾回收:GC和用户程序并行。
存在的问题:一定程度上利用多核计算机的优势减少了对用户程序的干扰,不过写屏障的额外开销和保守性问题仍然存在,这是不可避免的。
go v1.5至今都是基于三色标记法实现的并发式GC,将长时间的STW分为分割为多段短的STW,GC大部分执行过程都是和用户代码并行的。
参考:Golang 垃圾回收
辅助GC解决的问题是?
当用户分配内存的速度超过gc回收速度时,golang会通过辅助GC暂停用户程序进行gc,避免内存耗尽问题。
辅助GC干了什么?
辅助标记在垃圾回收标记的阶段进行,当用户程序分配内存的时候,先进行指定的扫描任务,即分配了多少内存就要完成多少标记任务。
参考:Golang 垃圾回收
runtime/proc.go
里的forcegcperiod
参数;runtime.GC()
手动触发;string
拼接时使用string.join
,而不是+号(go中string只读,每一个针对string的操作都会创建一个新的string)。三色标记法、混合写屏障。
参考1:深入理解屏障技术
Go1.8
版本引入了混合写屏障机制
,避免了对栈的重新扫描,大大减少了STW的时间。混合写屏障=插入屏障+删除屏障
,它是变形的弱三色不变性,结合了两者的优点。
channel主要用于协程之间
通信,属于内存级别
的通信。
参考1:channel的应用场景
应用场景:
select {
case <-time.After(time.Second):
select {
case <- time.Tick(time.Second)
ch := make(chan int, 5)
for _, url := range urls {
go func() {
ch <- 1
worker(url)
<- ch
}
}
channel的底层结构实现是hchan
,所在位置:src/runtime/chan.go
。
type hchan struct {
qcount uint // total data in the queue
dataqsiz uint // size of the circular queue
buf unsafe.Pointer // points to an array of dataqsiz elements
elemsize uint16
closed uint32
elemtype *_type // element type
sendx uint // send index
recvx uint // receive index
recvq waitq // list of recv waiters
sendq waitq // list of send waiters
// lock protects all fields in hchan, as well as several
// fields in sudogs blocked on this channel.
//
// Do not change another G's status while holding this lock
// (in particular, do not ready a G), as this can deadlock
// with stack shrinking.
lock mutex
}
可以看到hchan
最后有一个mutex
(锁)类型的lock
字段,所有的发送和读取之前都要加锁,所以channel是线程安全
的。
hchan
各字段解读:
qcount:
channel中环形队列当前存在的元素总数,len()返回该值。dataqsiz:
环形队列的长度,即缓冲区可以容纳的元素数量,make时指定,cap()返回该值。buf:
是一个指针,指向实际存储数据的缓冲区,缓存区基于环形队列实现,是一个连续的内存区域,用于存储channel中的元素。elemsize:
单个元素的字节大小,用于确定每个元素在缓冲区中占用的空间。closed:
channel关闭标志,用于表示channel是否已经关闭。当channel被关闭时,这个字段的值会被设置为非零。elemtype:
元素的类型信息,包括元素的大小和对齐方式等。sendx:
向channel发送数据时,写入的位置索引。recvx:
从channel读数据时,读取的位置索引。recvq:
buf空时,用于接收数据的goroutine等待队列,存储的是等待从channel接收数据的goroutine。sendq:
buf满时,用于发送数据的goroutine等待队列,存储等待向channel发送数据的goroutine。lock:
互斥锁,所有发送和读取之前都要加锁,保证同一时刻,只允许一个协程操作,所以channel是线程安全的。channel在进行读写数据时,会根据无缓冲、有缓冲设置进行对应的阻塞唤起动作,它们之间还是有区别的。
总结: 有缓冲channel和无缓冲channel 的读写基本相差不大,只是多了缓冲数据区域的判断而已。
无缓冲的channel(也称为阻塞式channel)是一种用于在协程之间进行同步的通信方式。无缓冲的channel的读写操作具有阻塞特性,这意味着在特定条件下,读写先后顺序不同,处理也会有所不同,所以还得再进一步区分:
在这里,我们暂时认为有 2 个goroutine在使用channel通信,按先写再读的顺序,则具体流程如下:
可以看到,由于channel是无缓冲的,所以G1暂时被挂在sendq队列里,然后G1调用了gopark休眠了起来。
接着,又有goroutine G2来channel读取数据了:
此时G2发现sendq等待队列里有goroutine存在,于是直接从G1 copy数据过来,并且会对G1设置goready函数,这样下次调度发生时,G1就可以继续运行,并且会从等待队列里移除掉。
先读再写的流程跟上面一样。【只是流程一样】
G1暂时被挂在了recvq队列,然后休眠起来。
G2在写数据时,发现recvq队列有goroutine存在,于是直接将数据发送给G1。同时设置G1 goready函数,等待下次调度运行。
在分析完了无缓冲channel的读写后,我们继续看看有缓冲channel的读写。同样的,我们分为 2种情况。
这一次会优先判断缓冲数据区域是否已满,如果未满,则将数据保存在缓冲数据区域,即环形队列里。如果已满,则和之前的流程是一样的。
当G2要读取数据时,会优先从缓冲数据区域去读取,并且在读取完后,会检查sendq队列,如果goroutine有等待队列,则会将它上面的data补充到缓冲数据区域,并且也对其设置goready函数。
此种情况和无缓冲的先读再写是一样流程,此处不再重复说明。
ch := make(chan T)
无缓冲的channel是阻塞式的:
ch := make(chan T, 2)
第二个参数表示channel中可缓冲类型T的数据容量。只要当前channel里的元素总数不大于这个可缓冲容量,则当前的goroutine就不会被阻塞住。
参考1:golang 系列:channel 全面解析
创建这样一个nil的channel是没有意义,读、写channel都将会被阻塞住。一般为nil的channel主要用在select 上,让select不再从这个channel里读取数据,达到屏蔽case的目的。
ch1 := make(chan int)
ch2 := make(chan int)
go func() {
if !ok { // 某些原因,设置 ch1 为 nil
ch1 = nil
}
}()
for {
select {
case <-ch1: // 当 ch1 被设置为 nil 后,将不会到达此分支了。
doSomething1()
case <-ch2:
doSomething2()
}
}
当我们不再使用channel的时候,可以对其进行关闭:
close(ch)
提示:
有缓冲的通道和无缓冲的channel关闭结果都是一样的。
panic: send on closed channel
,然后退出程序。判断channel是否关闭可以通过返回状态是false或true来确定,返回false代表已经关闭。
if v, ok := <-ch; !ok {
fmt.Println("channel 已关闭,读取不到数据")
}
重复(多次)关闭channel会报panic: close of closed channel
(关闭已关闭的channel)。
关闭通道的注意事项有以下几点:
panic
。panic
。因此,在关闭通道之前,建议检查通道是否已经关闭。range
可以方便地遍历通道,当通道被关闭时,range
循环将会结束。在Golang
中,关闭一个通道时,通道中的值会被正常接收,即接收方会收到通道中的零值。因此,当关闭通道时,接收方无法通过接收到的零值来判断是否是因为通道关闭而接收到的。
通常来说,在Golang
中关闭通道时,是通过发送一个信号值告知接收方通道已经关闭。这样的信号值可以是某个特定的值,也可以通过额外的信息来传递。以下是一种常见的模式,使用一个额外的布尔类型的通道来表示是否关闭:
ch := make(chan int)
closeSignal := make(chan bool)
go func() {
// 一些业务逻辑,将结果发送到通道 ch
result := 42
ch <- result
// 关闭通道
close(ch)
// 发送关闭信号
closeSignal <- true
}()
// 接收结果
result := <-ch
fmt.Println(result)
// 等待关闭信号
<-closeSignal
fmt.Println("Channel closed")
在这个例子中,closeSignal
是一个用于传递关闭信号的通道。当通道ch
关闭时,会先发送结果值,然后再发送关闭信号。接收方先接收结果值,然后再等待关闭信号。这样可以确保接收方在接收到结果值后知道通道已经关闭。
总的来说,通常不依赖通道中的零值来判断通道是否关闭,而是使用额外的机制(如关闭信号通道)来明确地表示通道的关闭状态。这样可以更加清晰和可靠地处理通道的关闭。
参考1:golang 系列:channel 全面解析
不论是有缓冲通道和无缓冲通道,往channel里读写数据时是有可能被阻塞住的,一旦被阻塞,则需要其他的goroutine执行对应的读写操作,才能解除阻塞状态。
如果阻塞状态一直没有被解除,Go可能会报 fatal error: all goroutines are asleep - deadlock!
错误,所以在使用channel时要注意goroutine的一发一取,避免goroutine永久阻塞!
使用共享内存的话在多线程的场景下为了处理竞态,需要加锁,使用起来比较麻烦。另外使用过多的锁,容易使得程序的代码逻辑艰涩难懂,并且容易使程序死锁,死锁了以后排查问题相当困难,特别是很多锁同时存在的时候。
go语言的channel保证同一个时间只有一个goroutine能够读写channel里的数据,为开发者提供了一种优雅简单的工具,所以go原生的做法就是使用channel来通信,而不是使用共享内存来通信。
参考1:对未初始化的的chan进行读写,会怎么样?为什么?
综合:4.5.3 为nil的channel
和4.7 channel的deadlock(死锁)或channel一直阻塞会怎样
。
只声明未初始化的channel说的就是为nil时的情况,它会阻塞读写,如果一直处于阻塞状态会报死锁fatal error: all goroutines are asleep - deadlock!
。
答:读写未初始化的 chan 都会阻塞。
报 fatal error: all goroutines are asleep - deadlock!
为什么对未初始化的 chan 就会阻塞呢?
- 未初始化的 chan 此时是等于 nil,当它不能阻塞的情况下,直接返回 false,表示写 chan 失败。
- 当 chan 能阻塞的情况下,则直接阻塞 gopark(nil, nil, waitReasonChanSendNilChan, traceEvGoStop, 2), 然后调用 throw(s string) 抛出错误,其中 waitReasonChanSendNilChan 就是刚刚提到的报错 “chan send (nil chan)”。
- 未初始化的 chan 此时是等于 nil,当它不能阻塞的情况下,直接返回 false,表示读 chan 失败
- 当 chan 能阻塞的情况下,则直接阻塞 gopark(nil, nil, waitReasonChanReceiveNilChan, traceEvGoStop, 2), 然后调用 throw(s string) 抛出错误,其中 waitReasonChanReceiveNilChan 就是刚刚提到的报错 “chan receive (nil chan)”。
并发处理。有缓冲的channel可以控制并发数目,从而实现多线程并发处理。
首先判断channel是否关闭了,判断是关闭的channel后将这个通道设置为nil,因为设置为nil,这个通道就阻塞住了,select会选择其他没有阻塞的channel来执行,这样达到一个屏蔽的效果。
无缓冲的通道实质是通道容量为0,这是它和有缓冲通道的表象区别。
实质区别从4.2 channel 的数据结构
到4.3 无缓冲channel的读写
和4.4 有缓冲channel的读写
。
无缓冲的channel可以用来同步通信、超时等。有缓冲的channel可以用来解耦生产者、消费者,并发控制。
参考1:https://jishuin.proginn.com/p/763bfbd381cb
综合1、2、3可知,在操作为nil或关闭的channel会导致panic。
channel底层的结构是hchan,hchan最后有一个mutex(锁)类型的lock字段,所有的发送和读取之前都要加锁,所以channel是线程安全的。
是通过Go语言的通道特性和多重返回值的机制来实现的。
package main
import "fmt"
func main() {
//1、初始化
m1 := map[string]int{}
m2 := make(map[string]int, 10)
//2、插入数据
m1["AA"] = 10
m1["BB"] = 20
m1["CC"] = 30
m2["AA"] = 10
m2["BB"] = 20
m2["CC"] = 30
//3、访问数据
fmt.Println("m1 AA=", m1["AA"])
fmt.Println("m2 BB=", m2["BB"])
fmt.Println()
//4、删除
delete(m1, "AA")
delete(m2, "BB")
fmt.Println("m1 AA=", m1["AA"])
fmt.Println("m2 BB=", m2["BB"])
fmt.Println()
//5、遍历
for key, value := range m1 {
fmt.Println("m1 Key=", key, ";Value=", value)
}
fmt.Println()
for key, value := range m2 {
fmt.Println("m2 Key=", key, ";Value=", value)
}
}
未初始化的map的值是nil,使用函数len() 可以获取map中键值对的数目。
m1 := map[string]int{}
//或
person := map[string]string{
"name": "John",
"age": "30",
"city": "New York",
}
m2 := make(map[string]int, cap)
注意: 可以使用 make(),但不能使用 new() 来构造 map,如果错误的使用 new() 分配了一个引用对象,会获得一个空引用的指针,相当于声明了一个未初始化的变量并且取了它的地址。
map[key] = value
map[key]
delete(map, key)
Go语言中并没有为 map 提供任何清空所有元素的函数、方法,清空map的唯一办法就是重新 make一个新的map,不用担心垃圾回收的效率,Go语言中的并行垃圾回收效率比写一个清空函数要高效的多。
for key, value := range map {
fmt.Println("map Key=", key, ";Value=", value)
}
map创建后实际是返回了hmap
结构体,是使用数组+链表的形式实现的,使用拉链法消除hash冲突。
参考1:Golang源码探究 — map
开放寻址法、拉链法。
参考1:开放寻址法(有更详细的介绍)
开放寻址法是一种将所有的键值对都存储在一个大数组中的方法。当发生哈希冲突(多个键映射到同一个位置)时,开放寻址法会尝试在数组中的其他位置继续寻找空闲槽位,直到找到一个空槽位或者遍历整个数组。开放寻址法有几种不同的策略,包括线性寻址、二次寻址和双重哈希寻址。
拉链法是一种在哈希表的每个槽位中存储一个链表(或其他数据结构,比如红黑树),用于存储冲突的键值对。当发生哈希冲突时,新的键值对会被添加到对应槽位的链表中。这样,每个槽位可以存储多个键值对,并且链表的操作可以在冲突的情况下更加高效。
拉链法可以扩展到更复杂的数据结构,如平衡二叉搜索树,以提高在冲突时的查找效率。
参考1:Golang 中 map 探究
参考2:golang map实现原理浅析
参考3:Golang Map原理(底层结构、查找/新增/删除、扩缩容)
map的底层实现是一个哈希表,因此实现map的过程实际上就是实现哈希表的过程。在这个哈希表中,主要出现的结构体有两个,一个叫hmap(a header for a go map),一个叫bmap(a bucket for a Go map,通常叫其bucket)。
map
底层的数据结构是由hmap
实现的,hmap
的结构体是在runtime/map.go
:
// A header for a Go map.
type hmap struct {
// Note: the format of the hmap is also encoded in cmd/compile/internal/reflectdata/reflect.go.
// Make sure this stays in sync with the compiler's definition.
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
}
hmap
各字段解读:
count
:当前map中的键值对数量,调用len(map)返回这个值。flags
:标志位,用于表示map的状态。B
:2^B表示bucket的数量,B表示取hash后多少位来做bucket的分组,再多就要扩容了。noverflow
:溢出桶的个数。hash0
:hash seed
(hash 种子)一般是一个素数,用于计算哈希值。buckets
:指向bucket数组的指针(存储key val);大小:2^B,如果没有元素存入,这个字段可能为nil。oldbuckets
:在扩容期间,将旧的bucket数组放在这里,新buckets会是oldbuckets的两倍大,用于实现平滑的扩容操作。nevacuate
:即将迁移的旧桶编号,可以作为搬迁进度,小于nevacuate的表示已经搬迁完成。extra
:用于存储额外的信息,如迭代器状态等。bucket数组里存储的是bmap
,bmap
在runtime/map.go
中,它的所有字段如下:
// A bucket for a Go map.
type bmap struct {
// tophash generally contains the top byte of the hash value
// for each key in this bucket. If tophash[0] < minTopHash,
// tophash[0] is a bucket evacuation state instead.
tophash [bucketCnt]uint8
// Followed by bucketCnt keys and then bucketCnt elems.
// NOTE: packing all the keys together and then all the elems together makes the
// code a bit more complicated than alternating key/elem/key/elem/... but it allows
// us to eliminate padding which would be needed for, e.g., map[int64]int8.
// Followed by an overflow pointer.
}
但这只是表面,实际上在golang runtime时,编译器会动态为bmap
创建一个新结构:
type bmap struct {
topbits [8]uint8 //高位哈希值数组
keys [8]keytype // 存储key的数组
values [8]valuetype // 存储val的数组
pad uintptr // 内存对齐使用,可能不需要
overflow uintptr // bucket的8个key存满了之后,指向当前bucket的溢出桶
}
bmap就是hmap中的的bucket(桶)的底层数据结构,一个桶中可以存放最多8个key/value,map使用hash函数得到hash值决定分配到哪个桶,然后又会根据hash值的高8位来寻找放在桶的哪个位置,具体的map的组成结构如下图所示:
参考1:Golang Map 底层实现
参考2:Golang底层实现系列——map的底层实现
参考3:golang笔记——map底层原理
参考4:Golang源码探究 —— map
Golang源码探究 —— map
golang笔记——map底层原理
overflow
的bucket
数量过多:(等量扩容)bucket
总数 2^ B小于2^15时,如果overflow
的bucket
数量超过 2^B(未用于存储的bucket数量过多),就会触发扩容;【即bucket
数目不大于2 ^ 15,但是使用overflow
数目超过 2^B
就算是多了。】bucket
总数2^ B大于等于2^15,如果overflow
的bucket
数量超过 2^ 15,就会触发扩容。【即bucket
数目大于2^ 15,那么使用overflow
数目一旦超过2^15
就算是多了。】针对 1:我们知道,每个bucket有8个空位,在没有溢出,且所有的桶都装满了的情况下,负载因子算出来的结果是8。因此当负载因子超过6.5时,表明很多bucket都快要装满了,查找效率和插入效率都变低了。在这个时候进行扩容是有必要的。
针对2:是对第1点的补充。就是说在负载因子比较小的情况下,这时候map的查找和插入效率也很低,而第1点识别不出来这种情况。表面现象就是计算负载子的分子比较小,即map里元素总数少,但是bucket数量多(真实分配的bucket数量多,包括大量的overflow bucket)。
不难想像造成2. 溢出桶的数量太多。
这种情况的原因:不停地插入、删除元素。先插入很多元素,导致创建了很多bucket,但是负载因子达不到第 1 点的临界值,未触发扩容来缓解这种情况。之后,删除元素降低元素总数量,再插入很多元素,导致创建很多的overflow bucket,但就是不会触发第1点的规定,你能拿我怎么办?overflow bucket 数量太多,导致 key 会很分散,查找插入效率低得吓人,因此出台第2点规定。这就像是一座空城,房子很多,但是住户很少,都分散了,找起人来很困难。
在mapassign
中会判断是否要扩容:Golang源码探究 —— map
//触发扩容的时机
func mapassign(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer {
...
// If we hit the max load factor or we have too many overflow buckets,
// and we're not already in the middle of growing, start growing.
// 如果达到了最大的负载因子或者有太多的溢出桶
// 或是是已经在扩容中
if !h.growing() && (overLoadFactor(h.count+1, h.B) || tooManyOverflowBuckets(h.noverflow, h.B)) {
hashGrow(t, h)
goto again // Growing the table invalidates everything, so try again
}
}
判断负载因子超过 6.5:golang笔记——map底层原理
func overLoadFactor(count int, B uint8) bool {
return count > bucketCnt && uintptr(count) > loadFactorNum*(bucketShift(B)/loadFactorDen)
}
判断overflow buckets 太多:golang笔记——map底层原理
func tooManyOverflowBuckets(noverflow uint16, B uint8) bool {
// If the threshold is too low, we do extraneous work.
// If the threshold is too high, maps that grow and shrink can hold on to lots of unused memory.
// "too many" means (approximately) as many overflow buckets as regular buckets.
// See incrnoverflow for more details.
if B > 15 {
B = 15
}
// The compiler doesn't see here that B < 16; mask B to generate shorter shift code.
return noverflow >= uint16(1)<<(B&15)
}
map的两个扩容的时机,都会发生扩容。但是扩容的策略并不相同,毕竟两种条件应对的场景不同。但map扩容采用的都是渐进式,桶被操作(增删改)时才会重新分配。
Golang Map 底层实现
翻倍扩容
:针对的是 达到最大的负载因子 的情况,扩容后桶的数量为原来的两倍。Golang源码探究 —— map对于达到最大的负载因子的扩容,它是因为元素太多,而bucket数量太少,解决办法很简单:将B加 1,bucket 最大数量(2^ B)直接变成原来bucket数量的2倍。于是,就有新老bucket了。
注意: 这时候元素都在老bucket里,还没迁移到新的bucket来。而且,新bucket只是最大数量变为原来最大数量(2^ B)的 2 倍(2^B * 2)。golang笔记——map底层原理
等量扩容
:针对的是溢出桶的数量太多的情况,溢出桶太多了,导致查询效率低。扩容时,桶的数量不增加。Golang源码探究 —— map对于溢出桶的数量太多的扩容,其实元素没那么多,但是overflow bucket数特别多,说明很多bucket都没装满。解决办法就是开辟一个新bucket空间,将老bucket中的元素移动到新bucket,使得同一个bucket 中的key排列地更紧密。这样,原来在overflow bucket中的key可以移动到bucket中来。节省空间,提高bucket利用率,map的查找和插入效率自然就会提升。golang笔记——map底层原理
Golang源码探究 —— map
步骤一:
步骤二:
迁移数据
步骤三:
所有旧桶驱逐完成后,回收所有旧桶(oldbuckets)。
golang笔记——map底层原理
由于map扩容需要将原有的key/value重新搬迁到新的内存地址,如果有大量的key/value需要搬迁,会非常影响性能。因此Go map的扩容采取了一种称为“渐进式”地方式,每次最多只会搬迁2个bucket。
翻倍扩容(达到最大的负载因子):【可能会变,也可能不会变】因为新的buckets数量是之前的一倍,所以在迁移时要重新计算 key的哈希,才能决定它到底落在哪个bucket。例如,原来 B = 5,计算出key的哈希后,只用看它的低 5 位,就能决定它落在哪个bucket。扩容后,B变成了 6,因此需要多看一位,它的低 6 位决定key落在哪个bucket。因此,某个key在搬迁前后bucket序号可能和原来相等,也可能是相比原来加上 2^B(原来的 B 值),取决于hash值 第6位bit位是 0 还是 1。golang笔记——map底层原理
等量扩容(溢出桶的数量太多):【可能会变,也可能不会变】从老的buckets搬迁到新的buckets,由于bucktes数量不变,因此可以按序号来搬,比如原来在0号bucktes,到新的地方后,仍然放在0号buckets。【如果迁移后是紧密的按顺序排列,则不变;如果不按顺序排列,会变】golang笔记——map底层原理
参考1:为什么说Go的Map是无序的?
首先是For ... Range ...
遍历Map的索引的起点是随机的。
其次,往map中存入时就不是按顺序存储的,所以是无序的。
翻倍扩容和等量扩容都可能会发生无序的情况,原因看 5.3.6 翻倍扩容、等量扩容中Key的变化
。
map在扩容后,会发生key的搬迁,原来落在同一个bucket中的key,搬迁后,有些key就要远走高飞了(bucket序号加上了 2^B)。而遍历的过程,就是按顺序遍历bucket,同时按顺序遍历bucket中的key。搬迁后,key的位置发生了重大的变化,有些 key飞上高枝,有些key则原地不动。这样,遍历map的结果就不可能按原来的顺序了。
当我们在遍历go中的map时,并不是固定地从0号bucket开始遍历,每次都是从一个随机值序号的bucket开始遍历,并且是从这个 bucket的一个随机序号的cell开始遍历。这样,即使你是一个写死的map,仅仅只是遍历它,也不太可能会返回一个固定序列的 key/value对了。
从语法上看,是可以的。Go 语言中只要是可比较的类型都可以作为 key。除开 slice,map,functions 这几种类型,其他类型都是 OK 的。具体包括:布尔值、数字、字符串、指针、通道、接口类型、结构体、只包含上述类型的数组。这些类型的共同特征是支持 == 和 != 操作符,k1 == k2 时,可认为 k1 和 k2 是同一个 key。如果是结构体,只有 hash 后的值相等以及字面值相等,才被认为是相同的 key。很多字面值相等的,hash出来的值不一定相等,比如引用。
float 型可以作为 key,但是由于精度的问题,会导致一些诡异的问题,慎用之。
map 并不是一个线程安全的数据结构。多个协程同时读写同时读写一个 map,如果被检测到,会直接 panic。
如果在同一个协程内边遍历边删除,并不会检测到同时读写,理论上是可以这样做的。但是,遍历的结果就可能不会是相同的了,有可能结果遍历结果集中包含了删除的 key,也有可能不包含,这取决于删除 key 的时间:是在遍历到 key 所在的 bucket 时刻前或者后。
如果想要并发安全的读写,可以通过读写锁来解决:sync.RWMutex。
读之前调用 RLock() 函数,读完之后调用 RUnlock() 函数解锁;写之前调用 Lock() 函数,写完之后,调用 Unlock() 解锁。
无法对 map 的 key 或 value 进行取址,将无法通过编译。
如果通过其他 hack 的方式,例如 unsafe.Pointer 等获取到了 key 或 value 的地址,也不能长期持有,因为一旦发生扩容,key 和 value 的位置就会改变,之前保存的地址也就失效了。
不安全,只读是线程安全的,主要是不支持并发写操作的,原因是 map 写操作不是并发安全的,当尝试多个 Goroutine 操作同一个 map,会产生报错:fatal error: concurrent map writes
。所以map适用于读多写少的场景。
解决办法
:要么加锁,要么使用sync包中提供了并发安全的map,也就是sync.Map,其内部实现上已经做了互斥处理。
golang的map用的是hashmap,是使用数组+链表的形式实现的,使用拉链法
消除hash冲突。拉链法见:5.2.2 拉链法(map使用的方式)
参考1:https://www.jianshu.com/p/1132055d708b
map是检查是否有另外线程修改h.flag
来判断,是否有并发问题。
// 在更新map的函数里检查并发写
if h.flags&hashWriting == 0 {
throw("concurrent map writes")
}
// 在读map的函数里检查是否有并发写
if h.flags&hashWriting != 0 {
throw("concurrent map read and map write")
}
参考1:http://c.biancheng.net/view/34.html
map 在并发情况下,只读是线程安全的,同时读写是线程不安全的。会报panic:fatal error: concurrent map read and map write
,因为Go语言原生的map并不是并发安全的,对它进行并发读写操作的时候,需要加锁。
参考1:golang对map排序
golang中map元素是随机无序的,所以在对map range遍历的时候也是随机的,如果想按顺序读取map中的值,可以结合切片来实现。
如果想按顺序读取map中的值,可以结合切片来实现。
参考1:https://www.cnblogs.com/wuchangblog/p/16393070.html
不能,每个协程只能捕获到自己的 panic 不能捕获其它协程。
sync.Map是并发安全的。底层通过分离读写map和原子指令来实现读的近似无锁,并通过延迟更新的方式来保证读的无锁化。
sync.Map
特性:
sync.Map
的基本操作的完整代码:
package main
import (
"fmt"
"sync"
)
func main() {
//1、初始化
var sMap sync.Map
//2、插入数据
sMap.Store(1,"a")
sMap.Store("AA",10)
sMap.Store("BB",20)
sMap.Store(3,"CC")
//3、访问数据
fmt.Println("Load方法")
//Load:①如果待查找的key存在,则返回key对应的value,true;
lv1,ok1 := sMap.Load(1)
fmt.Println(ok1,lv1) //输出结果:true a
//Load:②如果待查找的key不存在,则返回nil,false
lv2,ok2 := sMap.Load(2)
fmt.Println(ok2,lv2) //输出结果:false <nil>
fmt.Println()
fmt.Println("LoadOrStore方法")
//LoadOrStore:①如果待查找的key存在,则返回key对应的value,true;
losv1,ok1 := sMap.LoadOrStore(1,"aaa")
fmt.Println(ok1,losv1) //输出结果:true a
//LoadOrStore:②如果待查找的key不存在,则返回添加的value,false
losv2,ok2 := sMap.LoadOrStore(2,"bbb")
fmt.Println(ok2,losv2) //输出结果:false bbb
fmt.Println()
fmt.Println("LoadAndDelete方法")
//LoadAndDelete:①如果待查找的key存在,则返回key对应的value,true,同时删除该key-value;
ladv1,ok1 := sMap.LoadAndDelete(1)
fmt.Println(ok1,ladv1) //输出结果:true a
//LoadAndDelete:②如果待查找的key不存在,则返回nil,false
ladv2,ok2 := sMap.LoadAndDelete(1)
fmt.Println(ok2,ladv2) //输出结果:false <nil>
//4、删除
fmt.Println()
fmt.Println("Delete方法")
sMap.Delete(2)
fmt.Println()
fmt.Println("Range方法")
// 5、遍历所有sync.Map中的键值对
sMap.Range(func(k, v interface{}) bool {
fmt.Println("k-v:", k, v)
return true
})
}
sync.Map无须初始化,直接声明即可使用。
var sMap sync.Map
sync.Map插入数据使用自带的Store(key,value)。源码解读 Golang 的 sync.Map 实现原理 有对Store
的源码分析。
sMap.Store(1,"a")
sMap.Store("AA",10)
注意:Store(key, value interface{})
参数都是interface{}类型,所以同一个sync.Map能存储不同类型的数据。源码:
func (m *Map) Store(key, value interface{}) {
}
sync.Map访问有三个方法:Load()、LoadOrStore()、LoadAndDelete()
Load(key interface{}) (value interface{}, ok bool)
源码解读 Golang 的 sync.Map 实现原理 有对 Load
的源码分析。 lv1,ok1 := sMap.Load(1)
fmt.Println(ok1,lv1) //输出结果:true a
lv2,ok2 := sMap.Load(2)
fmt.Println(ok2,lv2) //输出结果:false <nil>
LoadOrStore(key, value interface{}) (actual interface{}, loaded bool)
losv1,ok1 := sMap.LoadOrStore(1,"aaa")
fmt.Println(ok1,losv1) //输出结果:true a
losv2,ok2 := sMap.LoadOrStore(2,"bbb")
fmt.Println(ok2,losv2) //输出结果:false bbb
LoadAndDelete(key interface{}) (value interface{}, loaded bool)
ladv1,ok1 := sMap.LoadAndDelete(1)
fmt.Println(ok1,ladv1) //输出结果:true a
ladv2,ok2 := sMap.LoadAndDelete(1)
fmt.Println(ok2,ladv2) //输出结果:false <nil>
sync.Map删除用 Delete(key interface{})
,查看源码会发现它是调用的LoadAndDelete(key)
最终来实现的。源码解读 Golang 的sync.Map实现原理 有对Delete
的源码分析。
源码:
func (m *Map) Delete(key interface{}) {
m.LoadAndDelete(key)
}
同map一样,Go语言也没有为sync.Map提供任何清空所有元素的函数、方法,清空sync.Map的唯一办法就是重新声明一个新的sync.Map。
sync.Map使用Range
配合一个回调函数进行遍历操作,通过回调函数返回内部遍历出来的值,Range参数中回调函数的返回值在需要继续迭代遍历时,返回 true,终止迭代遍历时,返回 false。
sMap.Range(func(k, v interface{}) bool {
fmt.Println("k-v:", k, v)
return true
})
sync.Map 的实现原理可概括为:
保证读写一致
)参考1:源码解读 Golang 的 sync.Map 实现原理
参考2:Golang的Map并发性能以及原理分析
sync.Map是在sync/map.go
:
type Map struct {
mu Mutex
read atomic.Pointer[readOnly]
dirty map[any]*entry
misses int
}
sync.Map
各字段解读:
mu
:互斥锁,保护dirty字段,当涉及到dirty数据的操作的时候,需要使用这个锁。read
:只读的数据,实际数据类型为readOnly,也是一个map,因为只读,所以不会有读写冲突。实际上,实际也会更新read的entries,如果entry是未删除的(unexpunged),并不需要加锁。如果entry已经被删除了,需要加锁,以便更新dirty数据。dirty
:dirty中的数据除了包含当前的entries,它也包含最新的entries(包括read中未删除的数据,虽有冗余,但是提升dirty字段为read的时候非常快,不用一个一个的复制,而是直接将这个数据结构作为read字段的一部分),有些数据还可能没有移动到read字段中(即直接将dirty晋升为read)
。misses
:当从Map中读取entry的时候,如果read中不包含这个entry,会尝试从dirty中读取,这个时候会将misses加一,当misses累积到dirty的长度的时候, 就会将dirty晋升为read,避免从dirty中miss太多次。因为操作dirty需要加锁。【保证读写一致
】readOnly
结构体:
type readOnly struct {
m map[interface{}]*entry
amended bool
}
readOnly
各字段解读:
m
:内建map,m的value的类型为*entry
。
amended
:用于判断dirty
里是否存在read
里没有的key
,通过该字段决定是否加锁读dirty
,如果有则为true。
readOnly.m
和Map.dirty
存储的值类型是*entry,它包含一个指针p,指向用户存储的value值。
entry
数据结构则用于存储sync.Map中值的指针:
type entry struct {
p unsafe.Pointer // 等同于 *interface{}
}
当p指针指向expunged这个指针的时候,则表明该元素被删除,但不会立即从map中删除,如果在未删除之前又重新赋值则会重新使用该元素。
entry
各字段解读:
p
:指向用户存储的value值,p有三种状态。
参考1:Golang的Map并发性能以及原理分析
从图中可以看出,read map
和dirty map
中含有相同的一部分entry
,我们称作是normal entries
,是双方共享的。状态是p的值为nil
和unexpunged
时。
但是read map
中含有一部分entry
是不属于dirty map
的,而这部分entry
就是状态为expunged
状态的entry
。而dirty map
中有一部分entry
也是不属于read map
的,而这部分其实是来自Store
操作形成的(也就是新增的 entry
),换句话说就是新增的entry
是出现在dirty map
中的。
读取数据时首先从m.read
中读取,不存在的情况下,并且m.dirty
中有新数据,对m.dirty
加锁,然后从m.dirty
中读取。
read map
:是用来进行lock free操作的(其实可以读写,但是不能做删除操作,因为一旦做了删除操作,就不是线程安全的了,也就无法 lock free)。
dirty map
:是用来在无法进行lock free操作的情况下,需要lock来做一些更新工作的对象。
当需要不停地新增和删除的时候,会导致dirty map不停地更新,甚至在misses过多之后,导致dirty成为nil,并进入重建的过程,所以sync.Map适用于读多写少的场景
。
是否支持多协程并发安全。
参考1:sync.Map详解
sync.Map 适用于读多写少的场景。
对于写多的场景,会导致不断地从dirty map中读取,导致dirty map晋升为read map,这是一个 O(N) 的操作,会进一步降低性能。
接口的底层实现结构有两个结构体iface
和eface
,区别在于iface
类型的接口包含方法,而eface
则是不包含任何方法的空接口:interface{}
。这两个结构体都在runtime/runtime2.go
中。(Golang之接口底层分析)
iface
结构体,是在runtime/runtime2.go
中,它的所有字段如下:
type iface struct {
tab *itab
data unsafe.Pointer
}
iface
各字段解读:
tab
:指针类型,指向一个itab实体,它表示接口的类型以及赋给这个接口的实体类型。data
:则指向接口具体的值,一般而言是一个指向堆内存的指针。itab
结构体,是在runtime/runtime2.go
中,它的所有字段如下:
type itab struct {
inter *interfacetype
_type *_type
hash uint32 // copy of _type.hash. Used for type switches.
_ [4]byte
fun [1]uintptr // variable sized. fun[0]==0 means _type does not implement inter.
}
itab
各字段解读:
inter
:接口自身定义的类型信息,用于定位到具体interface
类型。_type
:接口实际指向值的类型信息,即实际对象类型,用于定义具体interface
类型;hash
:_type.hash
的拷贝,是类型的哈希值,用于快速查询和判断目标类型和接口中类型是否一致。fun
:动态数组,接口方法实现列表(方法集),即函数地址列表,按字典序排序,如果数组中的内容为空表示_type
没有实现inter
接口。itab.inter
是interface
的类型元数据,它里面记录了这个接口类型的描述信息,接口要求的方法列表就记录在interfacetype.mhdr
里。
interfacetype
结构体,是在runtime/type.go
中,它的所有字段如下:
type interfacetype struct {
typ _type
pkgpath name
mhdr []imethod
}
interfacetype
各字段解读:
typ
:接口的信息。pkgpath
:接口的包路径。mhdr
:接口要求的方法列表。iface
结构体详解:
tab._type
就是接口的动态类型,也就是被赋给接口类型的那个变量的类型元数据。itab
中的_type
和iface
中的data
能简要描述一个变量。_type
是这个变量对应的类型,data
是这个变量的值。
itab.fun
记录的是动态类型实现的那些接口要求的方法的地址,是从方法元数据中拷贝来的,为的是快速定位到方法。如果itab._type
对应的类型没有实现这个接口,则itab.fun[0]=0
,这在类型断言时会用到。
当fun[0]
为0时,说明_type
并没有实现该接口,当有实现接口时,fun
存放了第一个接口方法的地址,其他方法依次往下存放,这里就简单用空间换时间,其实方法都在_type
字段中能找到,实际在这记录下,每次调用的时候就不用动态查找了。
eface
结构体,是在runtime/runtime2.go
中,它的所有字段如下:
type eface struct {
_type *_type
data unsafe.Pointer
}
eface
各字段解读:
_type
:类型信息。data
:数据信息,指向数据指针。_type
结构体,是在runtime/type.go
中,它的所有字段如下:
type _type struct {
size uintptr
ptrdata uintptr // size of memory prefix holding all pointers
hash uint32
tflag tflag
align uint8
fieldAlign uint8
kind uint8
// function for comparing objects of this type
// (ptr to object A, ptr to object B) -> ==?
equal func(unsafe.Pointer, unsafe.Pointer) bool
// gcdata stores the GC type data for the garbage collector.
// If the KindGCProg bit is set in kind, gcdata is a GC program.
// Otherwise it is a ptrmask bitmap. See mbitmap.go for details.
gcdata *byte
str nameOff
ptrToThis typeOff
}
_type
各字段解读:
size
:类型占用内存大小。ptrdata
:包含所有指针的内存前缀大小。hash
:类型hash。tflag
:标记位,主要用于反射。align
:对齐字节信息。fieldAlign
:当前结构字段的对齐字节数。kind
:基础类型枚举值。equal
:比较两个形参对应对象的类型是否相等。gcdata
:GC类型的数据。str
:类型名称字符串在二进制文件段中的偏移量。ptrToThis
:类型元信息指针在二进制文件段中的偏移量。重点说明:
kindMask
取出特殊标记位。const (
kindBool = 1 + iota
kindInt
kindInt8
kindInt16
kindInt32
kindInt64
kindUint
kindUint8
kindUint16
kindUint32
kindUint64
kindUintptr
kindFloat32
kindFloat64
kindComplex64
kindComplex128
kindArray
kindChan
kindFunc
kindInterface
kindMap
kindPtr
kindSlice
kindString
kindStruct
kindUnsafePointer
kindDirectIface = 1 << 5
kindGCProg = 1 << 6
kindMask = (1 << 5) - 1
)
str
和ptrToThis
,对应的类型是nameoff
和typeOff
。分表表示name
和type
针对最终输出文件所在段内的偏移量。在编译的链接步骤中,链接器将各个.o
文件中的段合并到输出文件,会进行段合并,有的放入.text
段,有的放入.data
段,有的放入.bss
段。nameoff
和typeoff
就是记录了对应段的偏移量。参考1:Go语言接口的nil判断
答:可以比较,因为nil
在Go语言中只能被赋值给指针和接口。接口在底层的实现主要考虑eface
结构体,它有两个部分:type
和data
。
两种情况:
nil
赋值给接口时,接口的type
和data
都将为nil
。此时,接口与nil
值判断是相等的。nil
赋值给接口时,只有data
为nil
,而type
不为nil
,此时,接口与nil
判断将不相等。参考1:golang中接口值(interface)的比较
这个问题,接口在底层的实现主要考虑eface
结构体,它有两个部分:type
和data
。interface
可以使用==
或!=
比较。
2个interface 相等有以下 2 种情况:
参考1:golang的context
在Golang
的http
包的Server
中,每一个请求都有一个对应的goroutine
负责处理,请求处理函数通常会启动额外的goroutine
去处理,当一个请求被取消或者超时,所有用来处理该请求的goroutine都应该及时退出,这样系统才能释放这些goroutine占用的资源,就不会有大量的goroutine去占用资源。
注意:go1.6及之前版本请使用golang.org/x/net/context。go1.7及之后已移到标准库context。
参考1:golang 系列:context 详解
参考2:快速掌握 Golang context 包,简单示例
从Context
的功能可以看出来,它是用来传递信息
的。这种传递并不仅仅是将数据塞给被调用者,它还能进行链式传递
,通过保存父子Context
关系,不断的迭代遍历来获取数据。
因为Context
可以链式传递,这就使得goroutine
之间能够进行链式的信号通知了,从而进而达到自上而下的通知效果。例如通知所有跟当前context有关系的goroutine进行取消处理。
因为Context
的调用是链式的,所以通过WithCancel
,WithDeadline
,WithTimeout
或WithValue
派生出新的Context
。当父Context
被取消时,其派生的所有Context
都将取消。
通过context.WithXXX
都将返回新的Context
和CancelFunc
。调用CancelFunc
将取消子代,移除父代对子代的引用,并且停止所有定时器。未能调用CancelFunc
将泄漏子代,直到父代被取消或定时器触发。go vet
工具检查所有流程控制路径上使用CancelFuncs
。
参考1:https://www.qycn.com/xzx/article/9390.html 本文中的四种使用场景的分析和相关代码同参考1完全相同。
1. RPC调用
在主goroutine上有4个RPC,RPC2/3/4是并行请求的,我们这里希望在RPC2请求失败之后,直接返回错误,并且让RPC3/4停止继续计算。这个时候,就使用的到Context。
代码:
package main
import (
"context"
"sync"
"github.com/pkg/errors"
)
func Rpc(ctx context.Context, url string) error {
result := make(chan int)
err := make(chan error)
go func() {
// 进行RPC调用,并且返回是否成功,成功通过result传递成功信息,错误通过error传递错误信息
isSuccess := true
if isSuccess {
result <- 1
} else {
err <- errors.New("some error happen")
}
}()
select {
case <- ctx.Done():
// 其他RPC调用调用失败
return ctx.Err()
case e := <- err:
// 本RPC调用失败,返回错误信息
return e
case <- result:
// 本RPC调用成功,不返回错误信息
return nil
}
}
func main() {
ctx, cancel := context.WithCancel(context.Background())
// RPC1调用
err := Rpc(ctx, "http://rpc_1_url")
if err != nil {
return
}
wg := sync.WaitGroup{}
// RPC2调用
wg.Add(1)
go func(){
defer wg.Done()
err := Rpc(ctx, "http://rpc_2_url")
if err != nil {
cancel()
}
}()
// RPC3调用
wg.Add(1)
go func(){
defer wg.Done()
err := Rpc(ctx, "http://rpc_3_url")
if err != nil {
cancel()
}
}()
// RPC4调用
wg.Add(1)
go func(){
defer wg.Done()
err := Rpc(ctx, "http://rpc_4_url")
if err != nil {
cancel()
}
}()
wg.Wait()
}
这里使用了waitGroup
来保证main函数在所有RPC调用完成之后才退出。
在Rpc函数中,第一个参数是一个CancelContext,这个Context形象的说,就是一个传话筒,在创建CancelContext的时候,返回了一个听声器(ctx)和话筒(cancel函数)。所有的goroutine都拿着这个听声器(ctx),当主goroutine想要告诉所有goroutine要结束的时候,通过cancel函数把结束的信息告诉给所有的goroutine。当然所有的goroutine都需要内置处理这个听声器结束信号的逻辑(ctx->Done())。我们可以看Rpc函数内部,通过一个select来判断ctx的done和当前的rpc调用哪个先结束。
这个WaitGroup和其中一个RPC调用就通知所有RPC的逻辑,其实有一个包已经帮我们做好了。errorGroup。具体这个errorGroup包的使用可以看这个包的test例子。
有人可能会担心我们这里的cancel()会被多次调用,context包的cancel调用是幂等的。可以放心多次调用。
我们这里不妨品一下,这里的Rpc函数,实际上我们的这个例子里面是一个“阻塞式”的请求,这个请求如果是使用http.Get或者http.Post来实现,实际上Rpc函数的Goroutine结束了,内部的那个实际的http.Get却没有结束。所以,需要理解下,这里的函数最好是“非阻塞”的,比如是http.Do,然后可以通过某种方式进行中断。
比如像这篇文章Cancel http.Request using Context中的这个例子:
func httpRequest(
ctx context.Context,
client *http.Client,
req *http.Request,
respChan chan []byte,
errChan chan error
) {
req = req.WithContext(ctx)
tr := &http.Transport{}
client.Transport = tr
go func() {
resp, err := client.Do(req)
if err != nil {
errChan <- err
}
if resp != nil {
defer resp.Body.Close()
respData, err := ioutil.ReadAll(resp.Body)
if err != nil {
errChan <- err
}
respChan <- respData
} else {
errChan <- errors.New("HTTP request failed")
}
}()
for {
select {
case <-ctx.Done():
tr.CancelRequest(req)
errChan <- errors.New("HTTP request cancelled")
return
case <-errChan:
tr.CancelRequest(req)
return
}
}
}
它使用了http.Client.Do,然后接收到ctx.Done的时候,通过调用transport.CancelRequest来进行结束。
我们还可以参考net/dail/DialContext。
换而言之,如果希望实现的包是“可中止/可控制”的,那么在包实现的函数里面,最好是能接收一个Context
函数,并且处理了Context.Done。
2. PipeLine
pipeline
模式就是流水线模型,流水线上的几个工人,有n个产品,一个一个产品进行组装。其实pipeline模型的实现和Context并无关系,没有context我们也能用chan实现pipeline模型。但是对于整条流水线的控制,则是需要使用上Context的。这篇文章Pipeline Patterns in Go的例子是非常好的说明。这里就大致对这个代码进行下说明。
runSimplePipeline的流水线工人有三个,lineListSource负责将参数一个个分割进行传输,lineParser负责将字符串处理成int64,sink根据具体的值判断这个数据是否可用。他们所有的返回值基本上都有两个chan,一个用于传递数据,一个用于传递错误。(<-chan string, <-chan error)输入基本上也都有两个值,一个是Context,用于传声控制的,一个是(in <-chan)输入产品的。
我们可以看到,这三个工人的具体函数里面,都使用switch处理了case <-ctx.Done()。这个就是生产线上的命令控制。
func lineParser(ctx context.Context, base int, in <-chan string) (
<-chan int64, <-chan error, error) {
...
go func() {
defer close(out)
defer close(errc)
for line := range in {
n, err := strconv.ParseInt(line, base, 64)
if err != nil {
errc <- err
return
}
select {
case out <- n:
case <-ctx.Done():
return
}
}
}()
return out, errc, nil
}
3. 超时请求
我们发送RPC请求的时候,往往希望对这个请求进行一个超时的限制。当一个RPC请求超过10s的请求,自动断开。当然我们使用CancelContext,也能实现这个功能(开启一个新的goroutine,这个goroutine拿着cancel函数,当时间到了,就调用cancel函数)。
鉴于这个需求是非常常见的,context包也实现了这个需求:timerCtx。具体实例化的方法是 WithDeadline 和 WithTimeout。
具体的timerCtx里面的逻辑也就是通过time.AfterFunc来调用ctx.cancel的。
官方的例子:
package main
import (
"context"
"fmt"
"time"
)
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancel()
select {
case <-time.After(1 * time.Second):
fmt.Println("overslept")
case <-ctx.Done():
fmt.Println(ctx.Err()) // prints "context deadline exceeded"
}
}
在http的客户端里面加上timeout也是一个常见的办法。
uri := "https://httpbin.org/delay/3"
req, err := http.NewRequest("GET", uri, nil)
if err != nil {
log.Fatalf("http.NewRequest() failed with '%s'\n", err)
}
ctx, _ := context.WithTimeout(context.Background(), time.Millisecond*100)
req = req.WithContext(ctx)
resp, err := http.DefaultClient.Do(req)
if err != nil {
log.Fatalf("http.DefaultClient.Do() failed with:\n'%s'\n", err)
}
defer resp.Body.Close()
在http服务端设置一个timeout如何做呢?
package main
import (
"net/http"
"time"
)
func test(w http.ResponseWriter, r *http.Request) {
time.Sleep(20 * time.Second)
w.Write([]byte("test"))
}
func main() {
http.HandleFunc("/", test)
timeoutHandler := http.TimeoutHandler(http.DefaultServeMux, 5 * time.Second, "timeout")
http.ListenAndServe(":8080", timeoutHandler)
}
我们看看TimeoutHandler的内部,本质上也是通过context.WithTimeout来做处理。
func (h *timeoutHandler) ServeHTTP(w ResponseWriter, r *Request) {
...
ctx, cancelCtx = context.WithTimeout(r.Context(), h.dt)
defer cancelCtx()
...
go func() {
...
h.handler.ServeHTTP(tw, r)
}()
select {
...
case <-ctx.Done():
...
}
}
context还提供了valueCtx的数据结构。这个valueCtx最经常使用的场景就是在一个http服务器中,在request中传递一个特定值,比如有一个中间件,做cookie验证,然后把验证后的用户名存放在request中。
我们可以看到,官方的request里面是包含了Context的,并且提供了WithContext的方法进行context的替换。
package main
import (
"net/http"
"context"
)
type FooKey string
var UserName = FooKey("user-name")
var UserId = FooKey("user-id")
func foo(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := context.WithValue(r.Context(), UserId, "1")
ctx2 := context.WithValue(ctx, UserName, "yejianfeng")
next(w, r.WithContext(ctx2))
}
}
func GetUserName(context context.Context) string {
if ret, ok := context.Value(UserName).(string); ok {
return ret
}
return ""
}
func GetUserId(context context.Context) string {
if ret, ok := context.Value(UserId).(string); ok {
return ret
}
return ""
}
func test(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("welcome: "))
w.Write([]byte(GetUserId(r.Context())))
w.Write([]byte(" "))
w.Write([]byte(GetUserName(r.Context())))
}
func main() {
http.Handle("/", foo(test))
http.ListenAndServe(":8080", nil)
}
在使用ValueCtx的时候需要注意一点,这里的key不应该设置成为普通的String或者Int类型,为了防止不同的中间件对这个key的覆盖。最好的情况是每个中间件使用一个自定义的key类型,比如这里的FooKey,而且获取Value的逻辑尽量也抽取出来作为一个函数,放在这个middleware的同包中。这样,就会有效避免不同包设置相同的key的冲突问题了。
参考1:快速掌握 Golang context 包,简单示例
Context
放入结构体,相反context
应该作为第一个参数传入,命名为ctx
。 func DoSomething(ctx context.Context,arg Arg)error { // ... use ctx ... }
。nil
的Context
。如果不知道用哪种Context
,可以使用context.TODO()
。context
的Value
相关方法只应该用于在程序和接口中传递的和请求相关的元数据,不要用它来传递一些可选的参数。Context
可以传递给在不同的goroutine
;Context
是并发安全的。context
的Done()
方法往往需要配合select { case }
使用,以监听退出。context
执行取消动作,所有派生的context
都会触发取消。参考1:快速掌握 Golang context 包,简单示例
参考2:golang 系列:context 详解
参考3:golang的context
Context是一个接口
,是在context/context.go
中,它的所有抽象方法如下:
type Context interface {
Deadline() (deadline time.Time, ok bool)
Done() <-chan struct{}
Err() error
Value(key interface{}) interface{}
}
Context
接口中抽象方法解读:
Deadline()
:返回截止时间和ok。
deadline
时间,同时ok为true
是表示设置了截止时间;Done()
:返回一个只读channel
(只有在被cancel后才会返回),它的数据类型是struct{}
,一个空结构体。当times out
或者父级Context
调用cancel
方法后,将会close channel
来进行通知,但是不会涉及具体数据传输,根据这个信号,开发者就可以做一些清理动作,比如退出goroutine
。多次调用Done
方法会返回的是同一个Channel
。
Err()
:返回一个错误。如果上面的Done()
的channel
没被close
,则error
为nil
;如果channel
已被close
,则error
将会返回close
的原因,说明该context
为什么被关掉,比如超时
或手动取消
。
Value()
:返回被绑定到Context
的值,是一个键值对,所以要通过一个Key
才可以获取对应的值,这个值一般是线程安全的。对于同一个上下文来说,多次调用Value
并传入相同的Key
会返回相同的结果,该方法仅用于传递跨API和进程间请求域的数据
。
参考1:golang中的context
参考2:golang的context
参考3:golang 系列:context 详解
Background()&TODO()
Background():
是所有派生Context
的根Context
,该Context
通常由接收request
的第一个goroutine
创建。它不能被取消、没有值、也没有过期时间,常作为处理request
的顶层context
存在。
TODO():
也是返回一个没有值的Context
,目前不知道它具体的使用场景,如果我们不知道该传什么类型的Context
的时候,可以使用这个。
Background()
和TODO()
本质上是emptyCtx
结构体类型,是一个不可取消,没有设置截止时间,没有携带任何值的空Context
,是直接return默认值,没有具体功能代码。一般的将它们作为Context的根,往下派生。
WithCancel(parent Context) (ctx Context, cancel CancelFunc)
:用来取消通知用的context
。
返回一个继承的Context
和CancelFunc取消方法
,在父协程context
的Done
函数被关闭时会关闭自己的Done
通道,或者在执行了CancelFunc取消方法
之后,会关闭自己的Done通道。这种关闭的通道可以作为一种广播的通知操作,告诉所有context
相关的函数停止当前的工作直接返回。通常使用场景用于主协程用于控制子协程的退出,用于一对多处理。
WithDeadline(parent Context, d time.Time) (Context, CancelFunc)
:timerCtx
类型的context
,用来超时通知。
参数是传递一个上下文,等待超时时间,超时后,会返回超时时间,并且会关闭context的Done通道,其他传递的context收到Done关闭的消息的,直接返回即可。同样用户通知消息出来。
以下三种情况会取消该创建的context:
1、到达指定时间点;
2、调用了CancelFunc取消方法;
3、父节点context关闭。
WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)
:timerCtx
类型的context
,用来超时通知。
WithTimeout()
里是直接调用并返回的WithDeadline()
,所以它和WithDeadline()
功能是一样,只是传递的时间是从当前时间加上超时时间。
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc) {
return WithDeadline(parent, time.Now().Add(timeout))
}
WithValue(parent Context, key, val interface{}) Context
:valueCtx
类型的context
,用来传值的context
。每个context
都可以放一个key-value
对, 通过WithValue
方法可以找key
对应的value
值,如果没有找到,就从父context
中找,直到找到为止。
WithCancel
、WithDeadline
、 WithTimeout
、WithValue
四个方法在创建的时候都会要求传父级context
进来,以此达到链式传递信息的目的。
参考1:https://blog.csdn.net/weixin_38664232/article/details/123663759
context本身是线程安全的,所以context携带value也是线程安全的。
context包提供两种创建根context的方式:
又提供了四个函数(WithCancel
、WithDeadline
、WithTimeout
、WithValue
)基于父Context
牌生,其中使用WithValue
函数派生的context
来携带数据,每次调用WithValue
函数都会基于当前context
派生一个新的子context
,WithValue
内部主要就是调用valueCtx类:
func WithValue(parent Context, key, val interface{}) Context {
if parent == nil {
panic("cannot create context from nil parent")
}
if key == nil {
panic("nil key")
}
if !reflectlite.TypeOf(key).Comparable() {
panic("key is not comparable")
}
return &valueCtx{parent, key, val}
}
说明:参数中的parent
是当前valueContext
的父节点。
valueCtx
结构如下:
type valueCtx struct {
Context
key, val interface{}
}
valueContext
继承父Context
,这种是采用匿名接口的继承实现方式,key
、val
用来存储携带的键值对。
通过上面的代码分析,可以发现:
添加键值对不是在原来的父Context
结构体上直接添加,而是以此context
作为父节点,重新创建一个新的valueContext子节点
,将键值对添加到子节点上,由此形成一条context
链。
获取键值对的过程也是层层向上调用,直到首次设置key的父节点,如果没有找到首次设置key的父节点,会向上遍历直到根节点,如果根节点找到了key就会返回,否则就会找到最终的根Context(emptyCtx)
返回nil。如下图所示:
总结: context添加的键值对是一个链式的,会不断衍生新的context,所以context本身是不可变的,因此是线程安全的。
context
包是Go
语言中用于在协程之间传递取消信号、截止时间和共享数据的一种机制。context
的并发安全性体现在以下几个方面:
context
的设计中鼓励不可变性,也就是说,一旦创建了context
,它的值就不会被改变。这确保了在协程之间传递context
时的线程安全性,因为不会有并发修改的情况。context
时,它可以基于已有的context
创建一个新的实例,并向其中添加或修改一些值。这个过程中,原始的context
实例不会受到影响,保证了并发安全。context
的方法返回一个新的context
,而不是修改原始的context
。例如,WithValue
方法就是返回一个带有新值的新context
实例,而不是在原始的context
上修改。这样的设计符合不可变性原则,从而确保并发安全。context
的取消机制,一个协程可以通知其他协程停止工作。这是通过context
的Done
通道来实现的。当一个协程调用cancel
函数时,Done
通道会被关闭,所有基于该context
的协程都能感知到取消信号。总体来说,context
的设计强调了不可变性和值的复制,这使得它在并发环境下能够提供一种安全而有效的机制,用于在协程之间传递相关信息,控制取消,以及传递截止时间等。在并发编程中,使用context
能够更容易地管理和传递与协程相关的信息,同时避免了共享状态带来的并发安全性问题。
参考1:go中select语句
select
语句是用来监听和channel
有关的IO
操作的,当IO
操作发生时,触发对应的case
动作。有了select
语句,可以实现main主线程
与goroutine线程
之间的互动。
//for {
select {
case <-ch1 : // 检测有没有数据可读
// 一旦成功读取到数据,则进行该case处理语句
case ch2 <- 1 : // 检测有没有数据可写
// 一旦成功向ch2写入数据,则进行该case处理语句
default:
// 如果以上都没有符合条件,那么进入default处理流程
}
}//
select语句外面可使用for循环来实现不断监听IO的目的。
注意事项:
select
语句只能用于channel
的IO操作,每个case
都必须是一个channel。default
条件,在没有IO
操作发生时,select
语句就会一直阻塞;如果有一个或多个IO操作同时发生时,Go运行时会随机选择一个case执行,但此时将无法保证执行顺序;
对于case语句,如果存在channel值为nil的读写操作,则该分支将被忽略,可以理解为相当于从select语句中删除了这个case;
default
条件,又一直没有IO
操作发生的情况,select
语句会引起死锁(fatal error: all goroutines are asleep - deadlock!
),如果不希望出现死锁,可以设置一个超时时间的case来解决;for
中的select
语句,不能添加default
,否则会引起CPU
占用过高的问题;参考1:go语言中select实现优先级
在 9.1 注意事项3
中已知无法保证执行顺序的情况。
问题描述:我们有一个函数会持续不间断地从ch1
和ch2
中分别接收任务1
和任务2
,如何确保当ch1
和ch2
同时达到就绪状态时,优先执行任务1
,在没有任务1
的时候再去执行任务2
呢?
实现代码:
func worker2(ch1, ch2 <-chan int, stopCh chan struct{}) {
for {
select {
case <-stopCh:
return
case job1 := <-ch1:
fmt.Println(job1)
case job2 := <-ch2:
priority:
for {
select {
case job1 := <-ch1:
fmt.Println(job1)
default:
break priority
}
}
fmt.Println(job2)
}
}
}
使用了嵌套的select,还组合使用了for
循环和label
来解决问题。上面的代码在外层select选中执行job2 := <-ch2
时,进入到内层select
循环继续尝试执行job1 := <-ch1
,当ch1
就绪时就会一直执行,否则跳出内层select
,继续执行job2
。
这是两个任务的情况,在任务数可数的情况下可以层层嵌套来实现对多个任务排序,对于有规律的任务可以使用递归的。
参考1:https://blog.csdn.net/eddycjy/article/details/122053524
注意:
关闭的channel
不是nil
,所以在select
语句中依然可以监听并执行对应的case
,只不过在读取关闭后的channel
时,读取到的数据是零值,ok是false。要想知道某个通道是否关闭,判断ok是否为false即可。
要想判断某个通道是否关闭,当返回的ok为false时,执行c = nil 将通道置为nil,相当于读一个未初始化的通道,则会一直阻塞。至于为什么读一个未初始化的通道会出现阻塞,可以看我的另一篇 对未初始化的的chan进行读写,会怎么样?为什么? 。select中如果任意某个通道有值可读时,它就会被执行,其他被忽略。则select会跳过这个阻塞case,可以解决不断读已关闭通道的问题。
参考1:https://blog.csdn.net/eddycjy/article/details/122053524
要想屏蔽某个已经关闭的通道,判断通道的ok是false
后,将channel
置为nil
,select
再监听该通道时,相当于监听一个未初始化的通道,则会一直阻塞,select
会跳过这个阻塞,从而达到屏蔽的目的。
参考1:https://blog.csdn.net/eddycjy/article/details/122053524
只有一个case的情况下,则会死循环。
关闭的channel
不是nil
,所以在select
语句中依然可以监听并执行对应的case
,只不过在读取关闭后的channel
时,读取到的数据是零值,ok是false。
参考1:https://blog.csdn.net/eddycjy/article/details/122053524
答:因为只有一个已经关闭的channel,且已经置为了nil,这时select会先阻塞,最后发生死锁(fatal error: all goroutines are asleep - deadlock!
)。
对于既不设置default
条件,又一直没有IO
操作发生的情况,select
语句会引起死锁(fatal error: all goroutines are asleep - deadlock!
),如果不希望出现死锁,可以设置一个超时时间的case来解决;
defer
的作用就是把defer
关键字之后的函数执行压入一个栈中延迟执行
,多个defer
的执行顺序是后进先出
LIFO,也就是先执行最后一个defer,最后执行第一个defer。
在这些操作中,最容易忽略的就是在每个函数退出处正确地释放和关闭资源。
参考1:go defer、return的执行顺序
多个defer
的执行顺序是后进先出
LIFO,也就是先执行最后一个defer,最后执行第一个defer。
参考1:go defer、return的执行顺序
参考2:Go语言中defer和return执行顺序解析
return返回值的运行机制:
return并非原子操作,共分为赋值、返回值两步操作。
defer、return、返回值三者的执行是:
return最先执行,先将结果写入返回值中(即赋值);接着defer开始执行一些收尾工作;最后函数携带当前返回值退出(即返回值)。
如果函数的返回值是无名的(不带命名返回值),则go语言会在执行return的时候会执行一个类似创建一个临时变量作为保存return值的动作,所以defer里面的操作不会影响返回值
。
package main
import (
"fmt"
)
func main() {
fmt.Println("return:", Demo()) // 打印结果为 return: 0
}
func Demo() int {
var i int
defer func() {
i++
fmt.Println("defer2:", i) // 打印结果为 defer: 2
}()
defer func() {
i++
fmt.Println("defer1:", i) // 打印结果为 defer: 1
}()
return i
}
代码示例,实际上一共执行了3步操作:
1)赋值,因为返回值没有命名,所以return 默认指定了一个返回值(假设为s),首先将i赋值给s,i初始值是0,所以s也是0。
2)后续的defer操作因为是针对i,进行的,所以不会影响s,此后因为s不会更新,所以s不会变还是0。
3)返回值,return s,也就是return 0
相当于:
var i int
s := i
return s
有名返回值的函数,由于返回值在函数定义的时候已经将该变量进行定义,在执行return的时候会先执行返回值保存操作,而后续的defer函数会改变这个返回值(虽然defer是在return之后执行的,但是由于使用的函数定义的变量,所以执行defer操作后对该变量的修改会影响到return的值
)。
由于返回值已经提前定义了,不会产生临时零值变量,返回值就是提前定义的变量,后续所有的操作也都是基于已经定义的变量,任何对于返回值变量的修改都会影响到返回值本身。
package main
import (
"fmt"
)
func main() {
fmt.Println("return:", Demo2()) // 打印结果为 return: 2
}
func Demo2() (i int) {
defer func() {
i++
fmt.Println("defer2:", i) // 打印结果为 defer: 2
}()
defer func() {
i++
fmt.Println("defer1:", i) // 打印结果为 defer: 1
}()
return i // 或者直接 return 效果相同
}
可以,在 10.2.2 defer、return、返回值 的执行返回值顺序
下有名返回值(函数返回值为已经命名的返回值)
的讲解中可以知道,可以更改。
有名返回值的函数,由于返回值在函数定义的时候已经将该变量进行定义,在执行return的时候会先执行返回值保存操作,而后续的defer函数会改变这个返回值(虽然defer是在return之后执行的,但是由于使用的函数定义的变量,所以执行defer操作后对该变量的修改会影响到return的值
)。
由于返回值已经提前定义了,不会产生临时零值变量,返回值就是提前定义的变量,后续所有的操作也都是基于已经定义的变量,任何对于返回值变量的修改都会影响到返回值本身。
参考1:循环内部使用defer的正确姿势
重点是理解defer的执行机制,defer是在函数退出的时候才执行的
,所以可以将打开关闭、文件等操作单独写到一个函数里,或者是写到匿名函数中。
参考1:golang之反射
反射基本介绍:
反射重要的函数:
reflect.TypeOf
(变量名),获取变量的类型,返回reflect.Type
类型;reflect.ValueOf
(变量名),获取变量的值,返回reflect.Value
类型,reflect.Value
是一个结构体类型。通过reflect.Value
,可以获取到关于该变量的很多信息。interface{}
和reflect.Value
是可以相互转换的,这点在实际开发中,会经常使用到。interface{} ——> reflect.Value:
rVal := reflect.ValueOf(b)
reflect.Value ——> interface{}:
iVal := rVal.Interface()
interface{} ——> 原来的变量(类型断言):
v := iVal.(Stu)
反射的注意事项:
//比如:
var num int = 10 //num的Type是int,Kind也是int
var stu Student //stu的Type是包名.Student,Kind是struct
interface{}
和Reflect.Value
之间相互转换:变量 <——> interface{} <——> reflect.Value
reflect.Value(x).Int()
,而不能使用其它的,否则报painc。SetXxx
方法来设置需要通过对应的指针类型来完成,这样才能改变传入的变量的值,同时需要使用到reflect.Value.Elem()
方法。参考1:Go语言反射(reflection)简述
看参考1的使用反射获取结构体的成员类型
部分。
package main
import (
"fmt"
"reflect"
)
func main() {
// 声明一个空结构体
type cat struct {
Name string
// 带有结构体tag的字段
Type int `json:"type" id:"100"`
}
// 创建cat的实例
ins := cat{Name: "mimi", Type: 1}
// 获取结构体实例的反射类型对象
typeOfCat := reflect.TypeOf(ins)
// 遍历结构体所有成员
for i := 0; i < typeOfCat.NumField(); i++ {
// 获取每个成员的结构体字段类型
fieldType := typeOfCat.Field(i)
// 输出成员名和tag
fmt.Printf("name: %v tag: '%v'\n", fieldType.Name, fieldType.Tag)
}
// 通过字段名, 找到字段类型信息
if catType, ok := typeOfCat.FieldByName("Type"); ok {
// 从tag中取出需要的tag
fmt.Println(catType.Tag.Get("json"), catType.Tag.Get("id"))
}
}
输出结果:
name: Name tag: ‘’
name: Type tag: ‘json:“type” id:“100”’
type 100
参考1:golang面试题:json包变量不加tag会怎么样?
private
。由于取不到反射信息,所以不能转成json。public
,可以转为json:代码:
package main
import (
"encoding/json"
"fmt"
)
type JsonTest struct {
aa string //小写无tag
bb string `json:"BB"` //小写+tag
CC string //大写无tag
DD string `json:"DJson"` //大写+tag
}
func main() {
jsonTest := JsonTest{aa: "1", bb: "2", CC: "3", DD: "4"}
fmt.Printf("转为json前jsonTest结构体的内容 = %+v\n", jsonTest)
jsonInfo, _ := json.Marshal(jsonTest)
fmt.Printf("转为json后的内容 = %+v\n", string(jsonInfo))
}
typ := reflect.TypeOf(data).Elem() //指针类型需要加 Elem()
val := reflect.ValueOf(data).Elem() //指针类型需要加 Elem()
使用场景:
JSON
解析、数据库操作等。通过反射,可以动态地获取和修改数据的类型和值。ORM
框架、测试框架等利用反射来提供通用的、可扩展的功能。限制和注意事项:
在实际应用中,要慎重使用反射,并确保它真的是解决问题的最佳选择。在大多数情况下,通过静态编码和类型安全的方式实现更为清晰和高效。
参考1:内存泄漏
内存泄漏指因为疏忽或错误造成程序未能释放已经不再使用的内存的情况。内存泄漏并不是指内存在物理上的消失,而是应用程序分配某段内存后,因为设计错误,失去了对该段内存的控制,因而造成了内存的浪费。
内存泄漏的危害:
长期运行的程序出现内存泄漏,影响很大,如操作系统、后台服务等等,出现内存泄漏会导致响应越来越慢,最终卡死。
默认的time.After()
是会有内存泄露问题的,因为每次time.After(duration x)
会产生NewTimer()
,在duration x
到期之前,新创建的timer
不会被垃圾回收,到期之后才会垃圾回收。
随着时间推移,尤其是duration x
很大的话,会产生内存泄露的问题,应特别注意。
for true {
select {
case <-time.After(time.Minute * 3):
// do something
default:
time.Sleep(time.Duration(1) * time.Second)
}
}
为了保险起见,使用NewTimer()
或者NewTicker()
代替的方式主动释放资源。
timer := time.NewTicker(time.Duration(2) * time.Second)
defer timer.Stop()
for true {
select {
case <-timer.C:
// do something
default:
time.Sleep(time.Duration(1) * time.Second)
}
}
在使用time.NewTicker时需要手动调用Stop()方法释放资源,否则将会造成永久性的内存泄漏。
timer := time.NewTicker(time.Duration(2) * time.Second)
// defer timer.Stop()
for true {
select {
case <-timer.C:
// do something
default:
time.Sleep(time.Duration(1) * time.Second)
}
}
使用select时如果有case没有覆盖完全的情况且没有default分支进行处理,会出现阻塞,最终导致内存泄漏。
goroutine
阻塞的情况func main() {
ch1 := make(chan int)
ch2 := make(chan int)
ch3 := make(chan int)
go Getdata("https://www.baidu.com",ch1)
go Getdata("https://www.baidu.com",ch2)
go Getdata("https://www.baidu.com",ch3)
select{
case v:=<- ch1:
fmt.Println(v)
case v:=<- ch2:
fmt.Println(v)
}
}
上面代码中这种情况会阻塞在ch3的消费处导致内存泄漏。
func main() {
fmt.Println("main start")
msgList := make(chan int, 100)
go func() {
for {
select {
case <-msgList:
default:
}
}
}()
c := make(chan os.Signal, 1)
signal.Notify(c, os.Interrupt, os.Kill)
s := <-c
fmt.Println("main exit.get signal:", s)
}
上述for循环条件一旦命中default则会出现循环空转的情况,并最终导致CPU暴涨。
channel阻塞主要分为写阻塞
和读阻塞
两种情况。
读写均会堵塞。
func channelTest() {
//声明未初始化的channel读写都会阻塞
var c chan int
//向channel中写数据
go func() {
c <- 1
fmt.Println("g1 send succeed")
time.Sleep(1 * time.Second)
}()
//从channel中读数据
go func() {
<-c
fmt.Println("g2 receive succeed")
time.Sleep(1 * time.Second)
}()
time.Sleep(10 * time.Second)
}
func channelTest() {
var c = make(chan int)
//10个协程向channel中写数据
for i := 0; i < 10; i++ {
go func() {
<- c
fmt.Println("g1 receive succeed")
time.Sleep(1 * time.Second)
}()
}
//1个协程丛channel读数据
go func() {
c <- 1
fmt.Println("g2 send succeed")
time.Sleep(1 * time.Second)
}()
//会有写的9个协程阻塞得不到释放
time.Sleep(10 * time.Second)
}
func channelTest() {
var c = make(chan int, 8)
//10个协程向channel中写数据
for i := 0; i < 10; i++ {
go func() {
<- c
fmt.Println("g1 receive succeed")
time.Sleep(1 * time.Second)
}()
}
//1个协程丛channel读数据
go func() {
c <- 1
fmt.Println("g2 send succeed")
time.Sleep(1 * time.Second)
}()
//会有写的几个协程阻塞写不进去
time.Sleep(10 * time.Second)
}
从channel读数据,但是没有goroutine往进写数据。
func channelTest() {
var c = make(chan int)
//1个协程向channel中写数据
go func() {
<- c
fmt.Println("g1 receive succeed")
time.Sleep(1 * time.Second)
}()
//10个协程丛channel读数据
for i := 0; i < 10; i++ {
go func() {
c <- 1
fmt.Println("g2 send succeed")
time.Sleep(1 * time.Second)
}()
}
//会有读的9个协程阻塞得不到释放
time.Sleep(10 * time.Second)
}
例如在for循环中申请过多的goroutine来不及释放导致内存泄漏。
I/O连接未设置超时时间,导致goroutine一直在等待,代码会一直阻塞。
goroutine无法获取到锁资源,导致goroutine阻塞。
当程序死锁时其他goroutine也会阻塞。
func mutexTest() {
m1, m2 := sync.Mutex{}, sync.RWMutex{}
//g1得到锁1去获取锁2
go func() {
m1.Lock()
fmt.Println("g1 get m1")
time.Sleep(1 * time.Second)
m2.Lock()
fmt.Println("g1 get m2")
}()
//g2得到锁2去获取锁1
go func() {
m2.Lock()
fmt.Println("g2 get m2")
time.Sleep(1 * time.Second)
m1.Lock()
fmt.Println("g2 get m1")
}()
//其余协程获取锁都会失败
go func() {
m1.Lock()
fmt.Println("g3 get m1")
}()
time.Sleep(10 * time.Second)
}
WaitGroup的Add、Done和wait数量不匹配会导致wait一直在等待。
当两个slice共享地址,其中一个为全局变量,另一个也无法被GC;
append slice后一直使用,没有进行清理。
var a []int
func test(b []int) {
a = b[:3]
return
}
由于数组时Golang的基本数据类型,每个数组占用不通的内存空间,生命周期互不干扰,很难出现内存泄漏的情况,但是数组作为形参传输时,遵循的时值拷贝
,如果函数被多个goroutine调用且数组过大时,则会导致内存使用激增。
//统计nums中target出现的次数
func countTarget(nums [1000000]int, target int) int {
num := 0
for i := 0; i < len(nums) && nums[i] == target; i++ {
num++
}
return num
}
因此对于大数组放在形参场景下通常使用切片或者指针进行传递,避免短时间的内存使用激增。
参考1:https://www.jb51.net/article/243510.htm
一共四种:
参考1:Golang = 比较与赋值
参考2:golang中如何比较struct,slice,map是否相等以及几种对比方法的区别
- 结构体只能比较是否相等,但是不能比较大小。
- 相同类型的结构体才能够进行比较,结构体是否相同不但与属性类型有关,还与属性顺序相关,sn3 与 sn1 就是不同的结构体;
- 如果 struct 的所有成员都可以比较,则该 struct 就可以通过 == 或 != 进行比较是否相等,比较时逐个项进行比较,如果每一项都相等,则两个结构体才相等,否则不相等;(像切片、map、函数等是不能比较的)
//定义1个set结构体 内部主要是使用了map
type set struct {
elements map[interface{}]bool
}
参考1:说说Golang的runtime
runtime
包含Go运行时的系统交互的操作,例如控制goruntine
的功能。还有debug
,pprof
进行排查问题和运行时性能分析,tracer
来抓取异常事件信息,如 goroutine
的创建,加锁解锁状态,系统调用进入推出和锁定还有GC相关的事件,堆栈大小的改变以及进程的退出和开始事件等等;race进行竞态关系检查以及CGO
的实现。总的来说运行时是调度器和GC。
fatal error: all goroutines are asleep - deadlock!
解决办法很简单,开辟两条协程,一条协程写,一条协程读。func main() {
ch := make(chan int, 0)
ch <- 666
x := <- ch
fmt.Println(x)
}
func main() {
ch := make(chan int,0)
ch <- 666
go func() {
<- ch
}()
}
我们可以看到,这条协程开辟在将数字写入到管道之后,因为没有人读,管道就不能写,然后写入管道的操作就一直阻塞。这时候就有疑惑了,不是开辟了一条协程在读吗?但是那条协程开辟在写入管道之后,如果不能写入管道,就开辟不了协程。
func main() {
chHusband := make(chan int,0)
chWife := make(chan int,0)
go func() {
select {
case <- chHusband:
chWife<-888
}
}()
select {
case <- chWife:
chHusband <- 888
}
}
func main() {
var rmw09 sync.RWMutex
ch := make(chan int,0)
go func() {
rmw09.Lock()
ch <- 123
rmw09.Unlock()
}()
go func() {
rmw09.RLock()
x := <- ch
fmt.Println("读到",x)
rmw09.RUnlock()
}()
for {
runtime.GC()
}
}
参考1:Go Exec 僵尸与孤儿进程
僵尸进程(zombie process)指:完成执行(通过exit系统调用,或运行时发生致命错误或收到终止信号所致),但在操作系统的进程表中仍然存在其进程控制块,处于“终止状态”的进程。
解决&预防:
收割僵尸进程的方法是通过kill
命令手工向其父进程发送SIGCHLD信号。如果其父进程仍然拒绝收割僵尸进程,则终止父进程,使得init
进程收养僵尸进程。init
进程周期执行wait
系统调用收割其收养的所有僵尸进程。
值传递和引用传递都有,看入参的类型。
值传递:
是指在调用函数时将实际参数复制一份传递到函数中,这样在函数中如果对参数进行修改,将不会影响到实际参数。
引用传递:
引用传递是指在调用函数时将实际参数的地址传递到函数中,那么在函数中对参数所进行的修改,将影响到实际参数,由于引用类型(slice、map、interface、channel)自身就是指针,所以这些类型的值拷贝给函数参数,函数内部的参数仍然指向它们的底层数据结构。
参考1:https://www.jianshu.com/p/93e205e70e83
Golang的引用类型包括 slice
、map
和 channel
。
参考1:make和new的区别
new
和 make
主要区别如下:
在讲new
和make
的使用场景之前,先介绍一下golang中的值类型和引用类型。
引用类型和值类型
值类型:
int
、float
、bool
和string
这些类型都属于值类型,使用这些类型的变量直接指向存在内存中的值,值类型的变量的值存储在栈中。当使用等号=
将一个变量的值赋给另一个变量时,如 j = i
,实际上是在内存中将 i 的值进行了拷贝。可以通过 &i 获取变量 i 的内存地址。 (struct在方法中传参时是值类型而非引用类型)
引用类型:
特指slice
、map
、channel
这三种预定义类型。能够通过make()
函数创建的都是引用类型,比如slice
和map
,slice
虽然看起来像数组,但是他其实是一个指向数组内存空间的一个指针类型。
使用场景:
参考1:互斥锁机制,互斥锁与读写锁区别
互斥锁和读写锁的区别:
NewTimer是延迟d时间后触发,如果需要循环则需要Reset。NewTimer的延迟时间并不是精确、稳定的,比如设置30ms,有可能会35、40ms后才触发,即使在系统资源充足的情况下,所以一个循环的timer在60ms内并不能保证会触发2两次,而ticker会。
它会调整时间间隔或者丢弃 tick 信息以适应反应慢的接者,所以回调触发不是稳定的,有可能在小于d的时间段触发,也有可能大于d的时间段触发,即使应用什么都不做。但在一段时间内,触发次数是保证的,比如在系统资源充足的情况下,设定触发间隔30ms,上一ticket触发间隔是44ms,下一触发间隔可能就是16ms,所以60ms内还是会触发两个ticket。
区别:
ticker的稳定性不如timer,一个空转的go程序,tickter也是不稳定的,触发间隔并不会稳定在d时间段,在ms级别上;而timer相对稳定,但也不是绝对的,timer也会在大于d的时间后触发。
回答:Golang语言上是使用多协程,还有架构设计方面等等。
答:并发处理。有缓冲的channel可以控制并发数目,从而实现多线程并发处理。
参考1:Go语言strconv包实现字符串和数值类型的相互转换
strconv包里有相关的转换方法:
string 与 int 类型之间的转换
Itoa():
整型转字符串。
Atoi():
字符串转整型。
Parse 系列函数
Parse
系列函数用于将字符串转换为指定类型的值,其中包括 ParseBool()
、ParseFloat()
、ParseInt()
、ParseUint()
。
Format 系列函数
Format
系列函数实现了将给定类型数据格式化为字符串类型的功能,其中包括 FormatBool()
、FormatInt()
、FormatUint()
、FormatFloat()
。
Append 系列函数
Append
系列函数用于将指定类型转换成字符串后追加到一个切片中,其中包含 AppendBool()
、AppendFloat()
、AppendInt()
、AppendUint()
。Append
系列函数和Format
系列函数的使用方法类似,只不过是将转换后的结果追加到一个切片中。参考1:golang标准库-sync包使用和应用场景
参考2:Golang - sync包的使用
WaitGroup、Once、Mutex、RWMutex、Cond、Pool、Map
。
参考1:golang 几种字符串的拼接方式
参考2:Golang的五种字符串拼接方式
+
号func BenchmarkAddStringWithOperator(b *testing.B) {
hello := "hello"
world := "world"
for i := 0; i < b.N; i++ {
_ = hello + "," + world
}
}
Golang里面的字符串都是不可变的,每次运算都会产生一个新的字符串,所以会产生很多临时的无用的字符串,不仅没有用,还会给GC带来额外的负担,所以性能比较差。
fmt.Sprintf()
func BenchmarkAddStringWithSprintf(b *testing.B) {
hello := "hello"
world := "world"
for i := 0; i < b.N; i++ {
_ = fmt.Sprintf("%s,%s", hello, world)
}
}
内部使用 []byte
实现,不像直接运算符这种会产生很多临时的字符串,但是内部的逻辑比较复杂,有很多额外的判断,还用到了 interface
,所以性能也不是很好。
strings.Join()
func BenchmarkAddStringWithJoin(b *testing.B) {
hello := "hello"
world := "world"
for i := 0; i < b.N; i++ {
_ = strings.Join([]string{hello, world}, ",")
}
}
join
会先根据字符串数组的内容,计算出一个拼接之后的长度,然后申请对应大小的内存,一个一个字符串填入,在已有一个数组的情况下,这种效率会很高,但是如果本来没有的话,去构造这个数据的代价也不小,效率也不高。
buffer.WriteString()
func BenchmarkAddStringWithBuffer(b *testing.B) {
hello := "hello"
world := "world"
for i := 0; i < 1000; i++ {
var buffer bytes.Buffer
buffer.WriteString(hello)
buffer.WriteString(",")
buffer.WriteString(world)
_ = buffer.String()
}
}
这个比较理想,可以当成可变字符使用,对内存的增长也有优化,如果能预估字符串的长度,还可以用 buffer.Grow() 接口来设置 capacity
。
几种方式的性能:
strings.Join()
能有比较好的性能。buffer.WriteString()
以获得更好的性能。fmt.Sprintf()
。参考1:Golang中int, int8, int16, int32, int64和uint区别
答:
我们看一下官方文档
int is a signed integer type that is at least 32 bits in size. It is a distinct type, however, and not an alias for, say, int32.
意思是 int 是一个至少32位的有符号整数类型。但是,它是一个不同的类型,而不是int32的别名。int 和 int32 是两码事。
uint is a variable sized type, on your 64 bit computer uint is 64 bits wide.
uint 是一种可变大小的类型,在64位计算机上,uint 是64位宽的。uint 和 uint8 等都属于无符号 int 类型。uint 类型长度取决于 CPU,如果是32位CPU就是4个字节,如果是64位就是8个字节。
总结:
go语言中的int的大小是和操作系统位数相关的,32位操作系统,int类型的大小是4字节【int32类型】。64位操作系统,int类型的大小是8个字节【int64类型】。
参考1:深入理解 Go Modules 的 go.mod 与 go.sum
go.mod
:
go.mod
:它用来标记一个 module 和它的依赖库以及依赖库的版本。会放在 module 的主文件夹下,一般以 go.mod 命名。go.sum
:go.sum 则是记录了所有依赖的 module 的校验信息,以防下载的依赖被恶意篡改,主要用于安全校验。参考1:Go 包管理与依赖查找顺序
go mod tidy
做了什么事情参考1:go mod tidy的作用
Asynq,基于Redis实现的。
参考1:GO:理解指针的作用
指针是指向了一个值的内存地址。
指针的作用:
参考1:golang 错误处理
panic
panic的引发:
发生panic
后,程序会从调用panic
的函数位置或发生panic
的地方立即返回,逐层向上执行函数的defer
语句,然后逐层打印函数调用堆栈,直到被recover
捕获或运行到最外层函数而退出。
此外,defer
逻辑里也可以再次调用panic
或抛出panic
。defer里面的 panic
能够被后续执行的 defer
捕获。
recover
recover()
用来捕获panic
,阻止panic
继续向上传递。recover()
和defer
一起使用,但是recover()
只有在defer
后面的函数体内被直接调用才能捕获panic
终止异常,否则返回nil
,异常继续向外传递。
即在defer中使用recover捕获并处理异常。
error
Go语言内置错误接口类型error
。任何类型只要实现Error() string
方法,都可以传递error
接口类型变量。Go语言典型的错误处理方式是将error
作为函数最后一个返回值。在调用函数时,通过检测其返回的error
值是否为nil
来进行错误处理。
参考1:Golang 的锁机制
Golang
中的锁分为互斥锁
、读写锁
、原子锁
即原子操作。
Golang
里有专门的方法来实现锁,就是 sync
包,这个包有两个很重要的锁类型。一个叫 Mutex
, 利用它可以实现互斥锁
。一个叫 RWMutex
,利用它可以实现读写锁
。
sync.Mutex
:互斥锁是同一时刻某一资源只能上一个锁,上锁后只能被此线程使用,直至解锁。加锁后即不能读也不能写。
sync.RWMutex
:读写锁将使用者分为读者
和写者
两个概念,支持同时多个读者一起读共享资源,但写时只能有一个,并且在写时不可以读。理论上来说,sync.RWMutex 的 Lock() 也是个互斥锁。
参考1:使用Golang时遇到的一些坑
参考1:Go项目实现优雅关机与平滑重启
优雅的关机:
优雅关机就是服务端关机命令发出后不是立即关机,而是等待当前还在处理的请求全部处理完毕后再退出程序,是一种对客户端友好的关机方式。而执行Ctrl+C
关闭服务端时,会强制结束进程导致正在访问的请求出现问题。
实现原理:
Go 1.8版本之后,在 os/signal
包中, http.Server
内置的 Shutdown()
方法就支持优雅地关机。
Shutdown工作的机制:当程序检测到中断信号时,我们调用http.Server
中的Shutdown()
方法,该方法将阻止新的请求进来,同时保持当前的连接,直到当前连接完成则终止程序!
流程:
8080端口开启了一个web服务,并且只注册了一条路由,“/”, 但客户端访问127.0.0.1:8080/时,过10秒才会响应,如果这时我们按下ctrl+c,给程序发送syscall.SIGINT信号,他会等待10秒将当前请求处理完,他才会消亡,当然也取决于创建的5秒的context超时时间。
代码:
package main
import (
"context"
"github.com/gin-gonic/gin"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
)
// 实现优雅关机和平滑重启
func main() {
router := gin.Default()
router.GET("/", func(c *gin.Context) {
// 这个10秒的延时。是为了演示操作方便,实际上线一定注释掉
time.Sleep(time.Second * 10)
c.String(http.StatusOK, "hello xiaosheng")
})
srv := &http.Server{
Addr: ":8080",
Handler: router,
}
// 必须开启一个go routine 因为如果不开起,下面会一直listen and serve,进入死循环
// err != http.ErrServerClosed这个很重要
go func() {
// 开启一个goroutine启动服务
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("listen : %s\n", err)
}
}()
// 等待中断信号来优雅关掉服务器, 为关闭服务器做一个5秒的延时
quit := make(chan os.Signal, 1)
// kill 默认会发送syscall.SIGTREN信号
// kill -2发送syscall.SIGINT信号,我们常用的ctrl+c就是触发系统SIGINT信号
// kill -9发送syscall.SIGKILL信号,但是不能被捕获,所以不需要添加他
// signal.Notify把收到的syscall.SIGINT或syscall.SIGTREN信号传给quit
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) // 此处不会阻塞
<-quit // 阻塞在此,当收到上述两种信号的时候才会往下执行
log.Println("ShutDown Server ...")
// 创建一个5秒超时的context
ctx, cancel := context.WithTimeout(context.Background(), time.Second*5)
defer cancel()
// 5秒内优雅关闭服务, (将未处理完的请求处理完再关闭服务), 超过5秒就退出
if err := srv.Shutdown(ctx); err != nil {
log.Fatal("shut down:", err)
}
log.Println("Server exiting...")
}
优雅的重启:(实际使用的比较少)
可以使用fvbock/endless
来替换默认的 ListenAndServe
启动服务来实现。
流程
go build -o graceful_restart
编译,并执行./graceful_restart
,终端输出当前pid(假设为44444)。hello xiaosheng!
修改为hello world!
,再次编译go build -o graceful_restart
。127.0.0.1:8080/
,此时客户端浏览器等待服务端返回响应。kill -1 44444
命令给程序发送syscall.SIGHUP
重启信号。代码:
import (
"log"
"net/http"
"time"
"github.com/fvbock/endless"
"github.com/gin-gonic/gin"
)
func main() {
router := gin.Default()
router.GET("/", func(c *gin.Context) {
// 这个5秒的延时。是为了演示操作方便,实际上线一定注释掉
time.Sleep(5 * time.Second)
c.String(http.StatusOK, "hello gin!")
})
// 默认endless服务器会监听下列信号:
// syscall.SIGHUP,syscall.SIGUSR1,syscall.SIGUSR2,syscall.SIGINT,syscall.SIGTERM和syscall.SIGTSTP
// 接收到 SIGHUP 信号将触发`fork/restart` 实现优雅重启(kill -1 pid会发送SIGHUP信号)
// 接收到 syscall.SIGINT或syscall.SIGTERM 信号将触发优雅关机
// 接收到 SIGUSR2 信号将触发HammerTime
// SIGUSR1 和 SIGTSTP 被用来触发一些用户自定义的hook函数
if err := endless.ListenAndServe(":8080", router); err!=nil{
log.Fatalf("listen: %s\n", err)
}
log.Println("Server exiting...")
这样做在不影响当前未处理完请求的同时完成了程序代码的替换,实现了平滑重启。但实际上用的不多
,因为实际都是多台服务器,或者说有类似supervisor的软件管理进程时就不适用这种方式,因为他进程pid变了,他自己重启和supervisor的软件管理进程给他重启就冲突了。
Golang
提供了内置的性能分析工具,如pprof
,用于分析内存使用情况。net/http/pprof
包,并在应用程序中启动一个HTTP
服务器,可以使用浏览器访问/debug/pprof
端点来查看内存分析数据。go tool pprof
工具来生成内存使用的火焰图:go tool pprof http://localhost:6060/debug/pprof/heap
Prometheus
、Grafana
等,也可以用于内存分析。goroutine
相关。go tool pprof
或runtime/pprof
包来查看当前正在运行的goroutine
以及其状态,以确定是否有未释放的资源。Golang
的标准库中包含了runtime/debug
包,可以用于检查堆中的对象数量和大小。debug.FreeOSMemory
来手动释放不再需要的内存。go tool trace
工具可以用于生成跟踪文件,该文件提供了详细的时间线信息,可用于识别内存问题。go vet
和golint
,可以帮助识别潜在的内存泄漏问题。要排查内存泄漏,通常需要综合使用多种方法,包括运行时工具、静态分析、代码审查和性能测试。请注意,内存泄漏问题可能会相当复杂,因此可能需要耐心和时间来诊断和解决。
在Go
中,要将interface{}
类型的数据转换为其他类型,可以使用类型断言(type assertion)
来完成。下面是将interface{}
类型的数据转换为string
和指定类型的结构体的示例:
将interface{}转换为string:
var val interface{} = "Hello, World" // 一个interface{}类型的变量
if str, ok := val.(string); ok {
// 转换成功,str 现在是一个 string
fmt.Println(str)
} else {
// 转换失败
fmt.Println("转换失败,不是一个字符串")
}
使用类型断言将其转换为string
类型。如果转换成功,ok
将为true
,并且str
将包含字符串的值。
将interface{}
转换为指定类型的结构体:
//结构体
type Person struct {
Name string
Age int
}
//将interface{}类型的数据转换为这个结构体类型
var val interface{} = Person{Name: "Alice", Age: 30} // 一个interface{}类型的变量
if person, ok := val.(Person); ok {
// 转换成功,person 现在是一个 Person 结构体
fmt.Println("Name:", person.Name)
fmt.Println("Age:", person.Age)
} else {
// 转换失败
fmt.Println("转换失败,不是一个 Person 结构体")
}
如果转换成功,ok
将为true
,并且person
将包含转换后的结构体。如果转换失败,则ok
为false
。
Go 1.20.5
版本包含了三个安全修复,如下:
cgo
时,go
命令可能在构建时生成意外的代码,导致运行一个使用cgo
的go
程序时出现意外的行为。这可能发生在运行一个包含有换行符的目录名的不可信模块时。使用go
命令获取的模块(即通过"go get"
)不受影响(使用GOPATH
模式获取的模块,即GO111MODULE=off
,可能受影响)。issue https://go.dev/issue/60167。Go
运行时在一个二进制文件设置setuid/setgid
位时没有做任何不同的处理。在Unix
平台上,如果一个setuid/setgid
二进制文件在执行时标准输入/输出文件描述符被关闭,打开任何文件可能导致以提升的权限读写意外的内容。类似地,如果一个setuid/setgid
程序被终止,无论是通过panic
还是信号,它可能泄露它的寄存器内容。issue https://go.dev/issue/60272。cgo
时,go
命令可能在构建时执行任意代码。这可能发生在运行"go get"
获取一个恶意模块时,或者运行任何其他构建不可信代码的命令时。这可以通过链接器标志触发,通过"#cgo LDFLAGS"
指令指定。issues https://go.dev/issue/60305 和 https://go.dev/issue/60306。然后我比如说现在有一个切片的一个对象,我是不断地扩容,然后可能从最开始的几个小k,然后几个小b,然后随着不断的扩容变成了几个大KB,然后但是这个对象我后来不引用了,或者说我这个对象我觉得它太大了,它 go 的话会有一种机制给它把它的占用的内存变小吗?
答:对于不再使用的切片,垃圾回收机制会自动回收。
在Go
中,Goroutine
是轻量级的用户态线程,而逻辑处理器P
是Go
运行时调度器的一部分,它负责管理Goroutine
的执行。操作系统线程M是运行时的底层执行单元,Go
运行时会维护一组M
以执行Goroutine
。
当一个Goroutine
发生读写并被阻塞时,以下是可能的情况:
Goroutine
需要等待某些资源,例如文件I/O
、网络请求、锁等,它会进入阻塞状态,不再执行。此时,该Goroutine
不会占用逻辑处理器P
的执行时间,以便其他Goroutine
可以在该P
上运行。Goroutine
阻塞时,其所在的逻辑处理器P
可能会变为空闲状态,因为没有可执行的任务。Go
运行时的调度器将会选择一个新的Goroutine
来运行,如果有多个逻辑处理器可用,那么其他逻辑处理器上的Goroutine
也会被执行。Goroutine
阻塞了,但底层的操作系统线程M
通常不会因此而阻塞。Go
运行时的调度器会继续管理操作系统线程M
,并确保它们在需要时可以运行其他Goroutine
。Goroutine
等待某个事件发生(如文件可读、网络连接成功等),它可能会进入等待事件通知的状态,这是一种非常高效的方式,因为它不会消耗CPU
时间,只有在事件发生时才会被唤醒。总结:
当一个Goroutine
阻塞时,逻辑处理器P
会寻找其他可运行的Goroutine
来填充其空闲时间,而操作系统线程M
仍然会保持活动状态以继续执行其他Goroutine
。这是Go
并发模型的一个关键特点,可以有效地管理大量Goroutine
,确保程序在并发执行中高效运行。
true
或false
Channel
类型Map
类型Golang
语言中表示Golang
中的rune
(int32
)类型,byte
(int8
别称),单引号里面是单个字符,对应的值为改字符的ASCII
值。string
,单个字符也是字符串,字符串可以有转义字符,如\n
、\r
、\t
等。在Golang
语言中的每个值都有一个类型,值的宽度由其类型定义,并且总是8 bits的倍数。借助unsafe.Sizeof
方法,来获取值的宽度。
空结构体在各类系统中频繁出现的原因之一,就是需要一个占位符,空结构体不占据内存空间,即便是变形处理也一样。
使用场景:
实现集合类型:
Golang
语言本身是没有集合类型(Set
),通常是使用map
来替代。但有个问题:就是集合类型,只需要用到key
(键),不需要用到value
(值),如果value
使用bool
来表示,实际会占用1个字节的空间,为了节省空间,这时就可以使用空结构体来替代。
package main
import "fmt"
type Set map[string]struct{}
func (s Set) Append(k string) {
s[k] = struct{}{}
}
func (s Set) Remove(k string) {
delete(s, k)
}
func (s Set) Exist(k string) bool {
_, ok := s[k]
return ok
}
func main() {
set := Set{}
set.Append("煎鱼")
set.Append("咸鱼")
set.Append("蒸鱼")
set.Remove("煎鱼")
fmt.Println(set.Exist("煎鱼"))
}
实现空通道:
在Golang
语言channel
的使用场景中,常常会遇到通知型channel
,其不需要发送任何数据,只是用于协调Goroutine
的运行,用于流转各类状态或是控制并发情况。
这类情况就特别适合使用空结构体,只做个占位,不浪费内存空间。
package main
func main() {
ch := make(chan struct{})
go worker(ch)
// Send a message to a worker.
ch <- struct{}{}
// Receive a message from the worker.
<-ch
println("AAA")
//输出:
//BBB
//AAA
}
func worker(ch chan struct{}) {
// Receive a message from the main program.
<-ch
println("BBB")
// Send a message to the main program.
close(ch)
}
实现方法接收者:
使用结构体类型的变量作为方法接收者,有时结构体可以不包含任何字段属性。这种情况,可以用int或者string来替代,但它们都会占用内存空间,所以使用空结构体是比较合适的。
并且也有利于未来针对该类型进行公共字段等的增加,容易扩展和维护。
package main
import "fmt"
type T struct{}
func methodUse() {
t := T{}
t.Print()
t.Print2()
//输出:
//哈哈哈Print
//哈哈哈Print2
}
func (t T) Print() {
fmt.Println("哈哈哈Print")
}
func (t T) Print2() {
fmt.Println("哈哈哈Print2")
}
总结:只用占位不用实际含义,那么我们就都可以使用空结构体,可以极大的节省不必要的内存开销。
在Go中,深度拷贝可以通过自定义函数来实现,遍历数据结构并创建一个完全独立的副本。有一些第三方库,如github.com/mohae/deepcopy
,也提供了深度拷贝的实现。需要注意的是,深度拷贝可能会涉及到性能和内存开销,因此在使用时需要谨慎考虑。
Go
的反射(Reflection
)机制提供了在运行时动态检查和操作变量、类型和结构的能力。虽然反射是一项非常强大的功能,但它通常比静态类型检查和编译时优化的代码要慢。这是因为反射需要在运行时进行类型检查和操作,而这些操作会引入额外的开销。
具体来说,Go
的反射速度相对较慢主要有以下原因:
Go
的编译器无法在反射代码上执行像静态代码一样的优化,因为反射的行为在编译时是未知的。尽管反射的性能较低,但在某些情况下仍然是有用的,例如编写通用库、处理未知类型的数据、实现ORM
(对象关系映射)等。然而,应该避免不必要的反射操作,尤其是在性能敏感的代码中。
如果性能是关键问题,应该尽量避免过度使用反射,而是在编译时尽可能静态类型检查和优化你的代码。在Go
中,反射通常被视为最后的选择,仅在必要时才使用它。
在Go
中,可以使用内置的测试工具和go test
命令来执行代码覆盖测试。代码覆盖测试可以帮助确定测试用例是否覆盖了代码库中的所有代码路径。以下是如何进行代码覆盖测试的步骤:
编写测试用例
:首先,编写测试用例,通常在与被测试的代码文件相同的目录中创建一个以 _test.go 结尾的文件,并编写测试函数。确保测试覆盖到要测试的各个功能和代码路径。testing
包提供的Cover
函数来启用代码覆盖率分析。例如:import "testing"
import "testing/quick"
func TestMyFunction(t *testing.T) {
// 启用代码覆盖率分析
c := testing.Cover(func() { myFunctionToTest() })
// 运行测试用例
if testing.Short() {
t.Skip("skipping test in short mode.")
}
// 运行快速检查测试
quick.Check(t, c, nil)
}
go test
命令来运行代码覆盖测试。确保使用-cover
标志来启用代码覆盖测试:go test -cover
运行测试后,Go
将生成一份代码覆盖率报告,显示哪些代码路径被测试覆盖,哪些没有。
4. 查看代码覆盖率报告:go test
命令运行后,会生成代码覆盖率报告,显示测试覆盖的代码行和百分比。可以查看报告,以确定哪些代码路径没有被覆盖,需要进一步的测试。
代码覆盖测试是一种重要的测试工具,可以帮助发现未被测试的代码路径和潜在的问题。它可以在开发周期中定期运行,以确保代码库的稳健性。同时,还可以使用一些工具来生成更详细的代码覆盖率报告,如go tool cover
。这些工具可以帮助更好地理解测试覆盖情况。
在Go
语言中,逃逸分析(Escape Analysis)是一种编译器优化技术,用于决定一个变量是否逃逸到堆上分配内存,或者可以在栈上分配。
逃逸分析的目标是尽可能地在栈上分配内存,因为在栈上分配内存比在堆上分配更加高效。栈上分配内存通常比堆上分配更快,因为它只是简单的移动栈指针,而不需要进行垃圾回收。
以下是逃逸分析的一些基本概念和规则:
逃逸分析的结果对于编译器的性能优化和程序的运行时行为有重要影响。通过减少对堆的使用,可以提高程序的性能,减小垃圾回收的压力。
在Go
语言中,可以使用-gcflags
标志来查看逃逸分析的结果,例如:
go build -gcflags="-m"
上述命令会输出关于逃逸分析的详细信息,包括哪些变量逃逸到堆上。这对于优化代码、理解代码的内存使用和性能分析都是有帮助的。
在Go语言中,内存逃逸(memory escape)是指在函数中分配的变量或对象被函数外部引用,从而导致这些变量或对象在堆上分配而不是在栈上。在Go中,栈上分配的变量有更短的生命周期,而堆上分配的变量则可能存活得更久。
产生内存逃逸的主要原因是编译器无法保证变量的生命周期仅限于函数的执行期间,因此将其分配到栈上可能导致在函数返回后仍然被引用。在这种情况下,编译器会将变量分配到堆上,以确保其在函数返回后仍然有效。
func createObject() *Object {
obj := Object{} // 局部变量
return &obj // 返回局部变量的指针
}
func closureExample() func() int {
x := 0
return func() int {
x++
return x
}
}
func concurrentExample() {
var data []int
go func() {
data = make([]int, 100)
// 修改 data
}()
// 在其他 goroutine 中使用 data
}
func interfaceExample() interface{} {
obj := Object{} // 局部变量
return obj // 返回局部变量的接口类型
}
编译器通过逃逸分析来判断是否将变量分配到堆上,以及何时将其分配到堆上。Go语言的编译器在编译时会进行逃逸分析,以尽可能减少堆分配,提高性能。然而,了解内存逃逸的原因仍然对于理解代码的性能特征和调试性能问题是有帮助的。
在Go
语言中,返回局部变量的函数是正常的,并且不会导致内存安全问题。Go
的编译器和运行时系统在处理这种情况时会进行逃逸分析,以确定是否需要在堆上分配内存。
如果一个局部变量没有逃逸到函数外部,即没有被函数外的代码引用,编译器会进行栈上分配。栈上分配内存是一种非常高效的方式,因为它只涉及移动栈指针,而不需要进行垃圾回收。局部变量在函数返回时会被自动销毁,不需要程序员手动释放。
以下是一个例子:
package main
import "fmt"
func localVariable() int {
// x 是局部变量,会在函数返回时自动销毁
x := 42
return x
}
func main() {
result := localVariable()
fmt.Println(result)
}
在这个例子中,localVariable
函数返回一个局部变量x
。由于x
没有逃逸到函数外部,因此编译器可以选择在栈上分配内存。
总体来说,Go
语言的设计和编译器优化使得返回局部变量是安全而高效的。在编写代码时,可以专注于代码的清晰性和可读性,而不必过度关心内存管理的细节。
Go语言提供了几种锁的实现,其中包括阻塞锁、饥饿锁等。以下是Go语言中常见的锁的种类:
var mu sync.Mutex
// 加锁
mu.Lock()
// 操作共享资源
// 解锁
mu.Unlock()
var rwmu sync.RWMutex
// 读锁定
rwmu.RLock()
// 读取共享资源
// 读解锁
rwmu.RUnlock()
// 写锁定
rwmu.Lock()
// 写入共享资源
// 写解锁
rwmu.Unlock()
var once sync.Once
func initialize() {
// 初始化操作
}
// 多个 goroutine 调用 initialize 函数,但只有第一个调用的 goroutine 会执行初始化操作
once.Do(initialize)
var wg sync.WaitGroup
// 添加一个 goroutine 到等待组
wg.Add(1)
go func() {
defer wg.Done()
// 执行操作
}()
// 等待所有 goroutine 完成
wg.Wait()
这些锁和同步机制可以帮助控制并发访问共享资源的行为。Go语言的标准库提供了丰富的同步工具,开发者可以根据具体的场景选择适当的锁。在选择锁时,需要根据具体的需求考虑性能、可伸缩性以及代码复杂度。
回答:sync包里的,全局安全的map,context、channel。
除了使用锁来保护全局变量外,Go 语言还提供了其他一些机制来防止全局变量被篡改。以下是其中一些常用的方式:
import "sync/atomic"
var counter int64
func increment() {
atomic.AddInt64(&counter, 1)
}
import "sync"
var (
mu sync.RWMutex
value int
)
func readValue() int {
mu.RLock()
defer mu.RUnlock()
return value
}
func writeValue(newValue int) {
mu.Lock()
defer mu.Unlock()
value = newValue
}
type ImmutableData struct {
Field1 int
Field2 string
}
var globalData = ImmutableData{Field1: 42, Field2: "Hello"}
// 读取全局变量
value1 := globalData.Field1
value2 := globalData.Field2
// globalData 是不可变的,不能修改其字段值
这些方法可以根据具体的场景和需求选择使用。在设计时,要根据并发性能、数据的访问模式以及代码的复杂性等因素权衡选择适当的保护机制。
回答:bytes、context、database、encoding、flag、fmt、go、html、io、log、math、net、os、path、reflect、regexp、runtime、sort、strings、sync、testing、time。
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。