当前位置: 首页 > article >正文

Go常见问题与答案笔记

这是一篇值的收藏的go常见问题与答案的笔记,内容包括了go的高级,如:悲观锁与乐观锁区别,for range赋值、waitgroup底层原理、go同步原语、defer关键字讲解。

文章目录

    • 1.悲观锁VS乐观锁的区别
    • 2.for range中赋值的变量,这个变量指向的是真实地址吗?还是临时变量?
    • 3.我能不能在写入channel的时候,判断是否阻不阻塞?
    • 4、如果我在defer里面修改return里面的值呢?这时怎么写?
    • 5、结构体中的tag有什么作用
      • JSON序列化与反序列化
      • 数据库操作
      • 字段验证
      • 反射读取结构体Tag
    • 6、waitgroup的底层原理是什么
    • 7、go语言有哪些同步原语
    • 8、如果chan在有缓冲的情况下缓冲区满了不想要后续的数据了怎么做
    • 9、内存泄漏有哪些场景
    • 10、切片的复制过程
    • 11、go里面如何解决hash冲突的
    • 12、切片与数组的区别
    • 13、go并发编程如何避免死锁
    • 14、defer关键字
    • 15、Map并发安全吗?如果是sync.map,它是如何保证并发安全?
    • 16、goroutine使用场景
    • 17、goroutine怎么做同步机制
    • 18、atomic介绍一下,它有哪些方法
    • 19、sync.Map数据写入流程
    • 20、sync.Map数据读取流程
    • 21、sync.Map中Dirty与Read转化过程
    • 22、Sync.map使用场景以及sync.map与map的区别
    • 23、go协程可能引发那些问题?
    • 24、go实现一个简单的多态
    • 25、go实现一个简单的cache
    • 26、golang select 两个channel性能稳定,三个channel性能会发生抖动,为什么
    • 27、生产者、消费者用有缓存channel通信场景,如何让生产者和消费者退出
    • 28、如何获取goroutine里面的一个函数执行的返回值
    • 29、反射原理以及反射应用场景
    • 30、 go哪些数据类型是线程安全的
    • 31、map可寻址吗?
    • 32、map扩容两种方式
    • 33、自旋锁的本质是什么?
    • 34、setnx和set nx区别
    • 35、string和byte的区别
    • 36、recover怎么使用的,defer相比普通在函数最后执行操作,其优势是什么?
    • 37、如何控制goroutine的生命周期,channel的作用,context的作用
    • 38、map,slice未初始化,操作会怎么样,发生panic应该怎么办?
    • 39、cookie与session的区别与应用
    • 40、context常见应用场景
    • 41、开辟多个写协程向channel写数据,有序的吗?
    • 42、 channel缓冲情况下接收与发送数据的流程
    • 43、关闭的channel接收与发送数据会出现什么情况
    • 44、channel底层结构分析
    • 45、面向对象的三大核心概念与五大核心准则
    • 46、进程、线程、协程、go协程的区别
    • 47、select核心机制与使用场景分析
    • 48、go语言实现一个线程池
    • 49、new与make的区别
    • 50、go当中同步锁有什么特点?作用是什么?
    • 51、如果在匿名函数内panic了,在匿名函数外的defer是否会触发panic-recover?反之在匿名函数外触发panic,是否会触发匿名函数内的panic-recover?
    • 52、读写锁的基本原理
    • 53、互斥锁基本原理
    • 54、REST API 详细规范
    • 55、go并发原语
      • goroutine
      • channel
      • select
      • sync包
    • 56、go使用什么类型?
    • 57、语言结构
    • 58、go数据类型
    • 59、函数与方法的区别
    • 60、go函数返回局部变量的指针是否安全?
    • 61、go函数参数传递到底是值传递还是引用传递
    • 62、defer关键字实现原理
    • 63、go内置函数make和new的区别
    • 64、go slice的底层实现原理
    • 65、go array和slice的区别
    • 66、slice深拷贝与浅拷贝
    • 67、slice扩容机制
    • 68、slice为什么不是线程安全的?
    • 69、map遍历为什么是无序的?
    • 70、map为什么是非线程安全的?
    • 71、mapp如何查找
    • 72、map冲突的解决方式?
    • 73、go map的负载因子为什么是6.5
    • 74、map如何扩容
    • 75、map和sync.Map谁的性能好,为什么?
    • 76、channel的底层实现原理
    • 77、channel有什么特点
    • 78、go channel有无缓冲的区别
    • 79、channel为什么设计成线程安全
    • 80、channel如何控制goroutine并发执行顺序
    • 81、go channel发送和接收什么情况下会死锁
    • 82、go互斥锁实现原理
    • 83、go互斥锁正常模式与饥饿模式
    • 84、go互斥锁允许自旋的条件
    • 85、go 读写锁的实现原理
    • 86、可重入锁
    • 87、原子操作有哪些
    • 88、原子操作和锁的区别
    • 89、goroutine底层实现原理
    • 90、goroutine和线程的区别
    • 91、goroutine泄露的场景
    • 92、如何查看正在执行的goroutine数量
    • 93、go线程实现模型
    • 94、GMP和GM模型
    • 95、Go调度原理
    • 96、work stealing机制
    • 97、Go hand off机制
    • 98、Go 抢占式调度
    • 99、go内存逃逸
    • 100、gc实现原理
    • 101、gc流程
    • 102、gc触发时机
    • 103、gc如何调优
    • 104、gc常用的并发模型?
    • 105、Cond实现原理
    • 106、go哪些方式安全读写共享变量
    • 107、如何盘查数据竞争

1.悲观锁VS乐观锁的区别

特性悲观锁乐观锁
加锁时机访问数据前加锁访问数据时不加锁,更新时检查
适用场景写场景较多的场景读操作较多的场景
性能开销加锁和解锁的开销较大无加锁开销,但可能需要重试
实现复杂度较简单较复杂(需要处理冲突和重试)
典型实现sync.Mutex、数据库行级锁atomic、数据库版本号机制
type Service struct {
	mutex   sync.Mutex
	version int
}

func (s *Service) Deploy() {
	s.mutex.Lock()
	defer s.mutex.Unlock()
	s.version++
}

type Service1 struct {
	version atomic.Int32
}

func (s *Service1) Deploy() {
	for {
		v := s.version.Load()
		n := v + 1
		if s.version.CompareAndSwap(v, n) {
			fmt.Println("更新成功")
			break
		}
	}
}

2.for range中赋值的变量,这个变量指向的是真实地址吗?还是临时变量?

如果在for range 里面有一个函数,这个函数需要传入一个指针,这个时候应该怎么写?

func main() {
	s := []int{10, 11, 12, 13, 14, 15, 16}
	for i, v := range s {
		fmt.Printf("%p,%p,%d,%d\n", &i, &v, i, v)
		f1(&v)
	}
	fmt.Println(s)
	mp := map[string]string{"a": "v1", "b": "v2", "c": "v3"}
	for k, v := range mp {
		fmt.Printf("%p,%p,%s,%s\n", &k, &v, k, v)
		f2(&v)
	}
	fmt.Println(mp)
}

func f1(i *int) {
	*i = 12
}
func f2(i *string) {
	*i = "aa"
}

3.我能不能在写入channel的时候,判断是否阻不阻塞?

(1)多路复用select,且有default情况下可以避免阻塞
(2)使用len与cap函数,可以检查channel状态

4、如果我在defer里面修改return里面的值呢?这时怎么写?

defer的执行顺序
defer执行会在我们函数return之后,而在外层函数去接受return值之前

func add(a,b int)(sum int){
	sum = a+b
	defer func() {
		sum = a + b
	}
	return 
}

5、结构体中的tag有什么作用

JSON序列化与反序列化

type Person struct {
	Name string `json:"name"`
	Age int `json:"age"`
}

通过结构体tag来指定字段的JSON键名

数据库操作

结构体tag用于指定数据库表和字段名

type User struct {
	ID int `gorm:"primaryKey"`
	Name string `gorm:"column:username"`
}

字段验证

validator库允许你使用tag来验证结构体字段的值是否合法

type Product struct {
	Name string `validate:"required"`
	Price float64 `validate:"gt=0"`
}

反射读取结构体Tag

可以读取和解析结构体的tag

import (
    "fmt"
    "reflect"
)

type Person struct {
    Name string `json:"name"`
    Age  int    `json:"age"`
}

func main() {
    p := Person{Name: "John", Age: 30}
    t := reflect.TypeOf(p)

    for i := 0; i < t.NumField(); i++ {
        field := t.Field(i)
        fmt.Println("Field:", field.Name, "Tag:", field.Tag)
    }
}

6、waitgroup的底层原理是什么

waitgroup是通过一个计数器来实现的,这个计数器跟踪一组goroutine的执行状态,确保主线程等待这些goroutine完成后才继续执行。
state主要是存储着状态和信号量,状态维护了2个计数器,1个是请求计数器counter,另外一个是等待计数器waiter
当数组首地址是处于一个8字节对齐的位置上时,那么就将这个数组前8个字节作为64位值使用表示状态,后4个字节作为32位值表示信号量,同理如果首地址没有处于8字节对齐的位置上时,那么就将前4个字节作为semaphore,后8个字节作为64位数值.
waitgroup的操作可以总结为三种方法:

  • Add(n int) : Add用于设置WaitGroup中等待的goroutine数量,通常在启动goroutine之前调用,n是需要等待的goroutine数量,可以是正数,也可以是负数。
  • Done() :Done用于减少WaitGroup的计数器,每个goroutine在执行完成时会调用Done() ,表示它已经结束,通常会在goroutine内部调用defer wg.Done(),以确保无论goroutine是否正常执行完成,都能减少计数器
  • Wait():它会阻塞当前goroutine直到waitgroup中的计数器变为0,通常用于主goroutine内,它会等待所有子gotouine完成后才继续执行

工作原理

  • 初始化:创建一个WaitGroup实例,初始时计数器值为0
  • 增加计数:通过调用Add(n)来增加计数器,表示启动了n个goroutine
  • 减少计数器:每个goroutine执行完成后,调用Done()减少计数器
  • 等待完成:主goroutine(或其他需要等待的goroutine)调用Wait()阻塞,直到计数器归0,即所有goroutine都已完成.

7、go语言有哪些同步原语

  • sync.Mutex:用于基本的互斥锁
  • sync.RWMutex:提供读写锁,适用于读多写少的场景
  • sync.WaitGroup:用于等待多个goroutine完成
  • sync.Once:确保某个操作只执行一次
  • sync.atomic:提供低级的原子操作,避免锁
  • sync.Cond:提供条件变量,用于复杂的同步场景.
  • channel:高级同步原语

8、如果chan在有缓冲的情况下缓冲区满了不想要后续的数据了怎么做

丢弃数据或者停止写入


func main() {
	ch := make(chan int, 10)
	done := make(chan struct{})
	//接受方
	go func() {
		for {
			select {
			case <-done:
				return
			default:
				select {
				case i := <-ch:
					fmt.Println(i)
				}
			}
		}
	}()

	go func() {
		i := 0
		for {
			select {
			case ch <- i:
				i++
				fmt.Println("case")
			default:
				close(done)
				close(ch)
				fmt.Println("default")
				return
			}
		}
	}()
	time.Sleep(1 * time.Second)
}

9、内存泄漏有哪些场景

  • goroutine没有及时关闭:比如让它tiime.sleep很久
  • 长时间存在的引用
  • 未关闭的资源(文件、网络、数据库等)
  • 错误的缓存使用
  • 闭包导致的引用循环
  • 使用map时未清理的数据
  • 未正确使用sync.Pool

10、切片的复制过程

  • 创建目标切片:复制前,目标切片(dst)必须有足够的容量来存放复制的数据。如果目标切片的容量不足,copy操作仍然会将源切片的数据复制到目标切片,直到目标切片的容量达到限制。
  • 元素复制:copy 函数会按顺序复制源切片中的元素到目标切片中,直到复制的元素数量达到目标切片或源切片的长度为止。

深拷贝,不会内存泄漏,

11、go里面如何解决hash冲突的

  • 计算哈希值:首先,Go 会通过哈希函数(例如 FNV-1a)来计算键的哈希值。
  • 定位桶:根据哈希值和当前哈希表的大小,确定目标桶的位置。
  • 冲突处理:如果桶已经有元素,Go会使用链式哈希(即将新元素添加到桶的链表中)。如果冲突过多,可能会触发再哈希操作,重新分配更多的桶并重新计算键的哈希值。

12、切片与数组的区别

  • 切片是对数组的引用

13、go并发编程如何避免死锁

sync、channel、waitgroup、资源竞争

14、defer关键字

  • defer执行位置,return之后,接受之前
  • 多个defer的执行顺序:栈结构
  • defer函数传参:形成闭包
  • defer异常捕获和处理:用recover

15、Map并发安全吗?如果是sync.map,它是如何保证并发安全?

Map在sync,atomic与channel里是并发安全,用互斥锁解决并发安全

16、goroutine使用场景

  • 并发任务,如web
  • 异步操作:如长时间运行的任务
  • 实时计算或数据处理:实时监控
  • 并行任务计算:分布式计算
  • 并发收据收集与汇总:数据合并
  • 工作池模型:任务分发

17、goroutine怎么做同步机制

  • 使用sync、atomic
  • 使用Channel

18、atomic介绍一下,它有哪些方法

atomic提供了一组原子操作,用于在并发环境下执行无锁的操作,避免了使用互斥锁的开销,原子操作是在多个线程(Goroutine)中对共享数据进行安全访问的操作,sync/atomic用于处理数值类型和指针类型

  • AddInt32/AddInt64
  • LoadInt32/LoadInt64
  • StoreInt32/StoreInt64
  • CompareAndSwapInt32/CompareAndSwapInt64
  • SwapInt32/SwapInt64

19、sync.Map数据写入流程

  • 尝试查找键:首先会尝试从 sync.Map 的 Read-Write Bucket 中找到对应的键。如果找到了,则更新这个键的值。
  • 数据迁移:如果该键已经存在于 Read-Write Bucket 中,且存在更新,则更新它。否则,若这个键不存在,sync.Map 会把它添加到 Read-Write Bucket 中。
  • 写入到 Dirty Map:如果进行写入时发生了 Store 操作的冲突,或者原本存在的值被替换,旧值会被移动到 Dirty Map中。Dirty Map 记录了需要延迟清理的映射数据。
  • 清理与回收:由于 sync.Map 的设计允许通过延迟清理来提高并发性能,所以在后续的操作中,Dirty Map 中的过时数据会被清理。

20、sync.Map数据读取流程

1、底层数据结构
sync.Map 的底层实现采用了多个数据结构,包括:

Read-Write Bucket (读取-写入桶):存储正常的键值对(map)。
Dirty Map (脏数据映射):存储正在修改的键值对,只有当一个键值对正在被更新时,才会临时移动到这个存储桶。
2. 读取流程(Load)
当调用 sync.Map.Load(key) 来读取数据时,以下步骤会发生:

直接查找 Read-Write Bucket:首先,sync.Map 会尝试从 Read-Write Bucket 中查找是否存在对应的键。如果键存在,直接返回值。

检查 Dirty Map:如果在 Read-Write Bucket 中找不到键,sync.Map 会检查 Dirty Map,看看是否有被标记为更新中的键。如果有,则从 Dirty Map 中获取数据。

返回结果:

如果找到了对应的键,Load 方法会返回该值,并且操作是并发安全的。
如果找不到对应的键,Load 方法会返回 nil(或者你可以指定默认值)以及 false,表示未找到该键。

21、sync.Map中Dirty与Read转化过程

1. 初始状态(Load 操作)
当你首次加载一个键时,sync.Map 会首先尝试在 Read-Write Bucket 中查找该键。如果键存在,就直接返回对应的值。
2. 更新操作(Store 操作)
当调用 Store 方法更新一个键时,如果该键的值已经存在于 Read-Write Bucket 中,那么它会被“标记为脏”(转移到 Dirty Map)。在并发情况下,这个操作避免了直接修改 Read-Write Bucket 中的内容,从而减少了锁的争用。

如果没有找到该键,新的键值对会直接插入到 Read-Write Bucket 中。

3. 查找未更新的键(Load 操作)
当查询一个键时,首先会在 Read-Write Bucket 查找,如果没有找到,再查看是否存在于 Dirty Map 中。通过这种方式,查询操作的效率得到了优化,不会因为写操作而造成查询的等待或阻塞。
4. 脏数据的处理(Dirty to Clean)
如果某个键的数据需要从脏映射中恢复(例如,经过某些操作后恢复到 Read-Write Bucket),它会被转移回正常的存储区域。这一过程是自动进行的,并且通常是并发安全的,通常由垃圾回收机制或者其他触发条件来管理。
5. 删除操作
在删除操作中,如果一个键的值被删除,它会被标记为“脏”,并移动到 Dirty Map。在删除过程中,脏映射可以快速完成删除,不会影响其他并发读操作。

22、Sync.map使用场景以及sync.map与map的区别

当给定键对应的条目只写入一次但会被多次读取时,比如:只增不减的缓存;当多个goroutine对互不相交的键集合进行读取,写入以及覆盖操作时,在这两种情况下,与搭配独立Mutex(互斥锁)或RWMutex(读写锁)的普通Map相比,会显著减少争用情况

23、go协程可能引发那些问题?

  • 竞态
  • 闭包
  • 死锁
  • 协程泄漏

24、go实现一个简单的多态

package main

import (
	"fmt"
	"math"
)

type Shape interface {
	Area() float64
}

type Rectangle struct {
	Width  float64
	Height float64
}

func (r Rectangle) Area() float64 {
	return r.Height * r.Width
}

type Circle struct {
	Radius float64
}

func (c Circle) Area() float64 {
	return math.Pi * c.Radius * c.Radius
}

func main() {
	shapes := []Shape{
		Rectangle{10, 20},
		Circle{10},
	}
	for _, shape := range shapes {
		fmt.Println(shape.Area())
	}
}

25、go实现一个简单的cache

type Cache struct {
	data map[string]interface{}
	mutex sync.RWMutex
}
func NewCache() *Cache {
	return &Cache{
		data : make(map[string]interface{})
	}
}
func (c *Cache)Set(key string,value interface{}){
	c.mutex.Lock()
	defer c.mutex.Unlock()
	c.data[key] = value
}
func (c *Cache)Get(key string)(interface{},bool){
	c.mutex.RLock()
	defer c.mutex.RUnlock()
	value,exists := c.data[key]
	return value,exists
}
func (c *Cache)Delete(key string){
	c.mutex.Lock()
	defer c.mutex.Unlock()
	delete(c.data,key)
}

26、golang select 两个channel性能稳定,三个channel性能会发生抖动,为什么

1. 调度器调度和竞争
Go 的调度器是基于抢占的。每个 goroutine 被调度时,它有一个时间片,用来执行。多 channel 的 select 会增加调度器的工作负担,尤其是当涉及到多个并发 goroutine 时。

当你在 select 中监听两个 channel 时,调度器选择哪个 channel 更为简单,因为它只需要在两个选项中做出决策。引入第三个 channel 时,调度器的选择就变得更加复杂。每次 select 都需要从三个可能的 channel 中选择一个,增加了上下文切换的开销,可能导致一些性能的不稳定,尤其是在高并发或高负载的情况下。

2. 通道阻塞和非阻塞行为
当 select 中的 channel 都没有准备好时,select 会阻塞等待其中任何一个 channel 的准备。对于 channel 的调度和阻塞行为,Go 调度器也会做出选择,哪个 channel 阻塞了多久以及哪个 channel 变得可用会影响调度的决策。

当你有多个 channel 时,某些 channel 可能经常会阻塞,而其他的可能会迅速准备好,造成选择的“波动”。这种波动可能在某些高并发场景下被放大,导致不稳定或抖动的表现。

3. 延迟和负载不均
假设你的 channel 和 goroutine 设计存在负载不均的问题,比如某些 channel 接受大量数据或者某些 goroutine 工作繁忙,可能会导致某些 select 中的通道频繁被选中,而另一些则较少被选中,从而导致性能上的不稳定。在三个 channel 的情况下,负载的不均衡可能更加显著。

4. CPU缓存和缓存失效
处理多个 channel 时,如果 goroutine 的工作负载较重,可能会导致 CPU 缓存不命中或缓存失效。不同的 channel 可能运行在不同的 CPU 核心上,而在 select 中频繁切换 channel 时,可能会造成缓存局部性差,进而影响性能的稳定性。

5. 调度器的上下文切换
select 语句内部选择多个 channel 时,如果多个 goroutine 在同一时间尝试访问这些 channel,则可能会引发更频繁的上下文切换。上下文切换本身是有一定性能开销的,尤其是当 select 中有多个 channel 时,频繁的选择和调度可能引起性能的抖动。

6. select 默认分支的影响
如果你的 select 中有 default 分支,默认分支可能会影响调度器的行为。当有多个 channel 时,默认分支有可能阻止其他 channel 的正常选择,导致性能抖动。

27、生产者、消费者用有缓存channel通信场景,如何让生产者和消费者退出

  • 通过关闭channel来通知退出
  • 通过控制信号来通知
  • 超时机制
package main

import (
	"fmt"
	"time"
)

func producer(ch chan<- int, done chan<- bool) {
	for i := 1; i <= 5; i++ {
		ch <- i
		fmt.Println("producer ", i)
		time.Sleep(time.Second)
	}
	close(ch)
	done <- true
}

func consumer(ch <-chan int, done <-chan bool) {
	for {
		select {
		case item, ok := <-ch:
			if !ok {
				fmt.Println("channel closed")
				return
			}
			fmt.Println("consumer ", item)
		case <-done:
			fmt.Println("consumer done")
			return
		}
	}
}

func main() {
	ch := make(chan int, 3)
	done := make(chan bool)
	go producer(ch, done)
	go consumer(ch, done)
	<-done
}

28、如何获取goroutine里面的一个函数执行的返回值

  • 通过内存进行通信
  • 通过通道进行通信

29、反射原理以及反射应用场景

反射是指在运行时动态地检查类型和修改对象的能力,他是基于reflect包实现的。
应用场景:

  • 框架和库实现:反射使得我们能够处理不确定类型的数据。
  • 接口的实现:通过反射,可以检查一个对象是否实现了某个接口。
  • 测试和调试:反射可用于动态地检查对象的状态。
  • 动态生成代码:反射可以用来在运行时动态生成代码

30、 go哪些数据类型是线程安全的

channel与sync.Map

31、map可寻址吗?

map不可寻址是因为它的底层实现设计上不支持直接通过引用进行修改。它的底层结构本身是动态变化的,并且在操作过程中可能会发生扩容等等操作。由于map的数据结构会发生变化,go设计上限制 了我们不能通过指针直接修改它,

32、map扩容两种方式

  • 基于负载因子扩容
  • 基于哈希冲突扩容

1. 基于负载因子的扩容
map 会在负载因子超过一定阈值时进行扩容。负载因子是 map 中元素的数量与底层数组大小的比率。

负载因子通常是 0.75,即当 map 中的元素数量达到其容量的 75% 时,会触发扩容。
具体扩容过程:
当 map 插入新的键值对时,如果发现负载因子超过了阈值(如 0.75),Go 会自动进行扩容,通常是将 map 的底层数组大小翻倍。
扩容时,Go 会重新计算所有现有元素的哈希值并将它们重新分配到新的数组位置上。这是因为 map 底层是基于哈希表实现的,扩容后,原有的哈希表位置可能发生变化。
2. 基于哈希冲突的处理扩容
map 的扩容也与哈希冲突的处理有关。map 使用了开放寻址法来解决哈希冲突。每当发生哈希冲突时,Go 会尝试寻找一个空的位置存储该键值对。

哈希冲突:当多个键的哈希值相同或相近时,它们会被存储在同一个位置,这时会发生冲突。Go 通过开放寻址法解决冲突,将冲突的元素存储到新的位置。
当冲突太多时,map 会进行扩容,增加桶(bucket)的数量,从而减少冲突的频率。这个过程是自适应的,取决于 map 中的元素数量和冲突情况。

33、自旋锁的本质是什么?

自旋锁本质是一种忙等待锁,核心思想是通过不断地检查锁的状态,直到能够成功获取锁为止,而不是让线程进入阻塞状态。这种方式相对较为简单,并且适用于哪些持锁时间非常短的场景。自旋锁的特点是获取不到锁时,线程会反复轮旋锁是否可用,而不是挂起自己

34、setnx和set nx区别

  • setnx是一个独立的命令,只在键不存在时设置键值
  • set nx是set命令时的一部分,它也只有在键不存在时才设置键值,但相比setnx,它支持更多的选项,如设置键的过期时间,持久化等。

35、string和byte的区别

特性stringbyte
数据类型字符串类型,表示一系列字符数字类型,表示单个字节
存储内容存储文本字符(通常使用utf-8编码)存储原始的二进制数据,通常是0到255之间的数字
使用场景处理文本数据、如字符串,文本内容,消息等处理二进制数据,如图像文件、加密数据、流数据等
可变性在许多语言中,string是不可变的byte是可变的
表示方式一系列字符或文本单个字节(通常是0到255的整数)

36、recover怎么使用的,defer相比普通在函数最后执行操作,其优势是什么?

recover是与panic相关的,它用于从panic中恢复,panic会导致程序中断并开始执行defer语句,如果在defer中调用recover,就可以捕获并恢复从panic中引发的恐慌,防止程序崩溃,即使遇到panic,defer也会执行,普通函数则不一样。

37、如何控制goroutine的生命周期,channel的作用,context的作用

goroutine可以通过channel与sync.Map控制,channel的作用是goroutine之间进行通信的机制,context用于控制并发操作的生命周期,特别是处理取消信号超时控制以及请求范围内的共享数据。

38、map,slice未初始化,操作会怎么样,发生panic应该怎么办?

一个未初始化的map变量是nil,即它指向一个空的映射(空指针)。如果我们尝试对nil的map进行写操作,会引发panic,但是,读操作对nil的map不会引发panic,返回的是map类型的零值。
slice是一个引用类型,未初始化时它的零值是nil。与map不同,对nil的slice进行操作(如追加、读取元素等)并不会引发panic,但会有一些不同的行为。
slice可以追加元素,可以正常使用append()函数向一个nil的slice追加元素,go会自动为其分配内存。
读取元素:如果尝试访问一个nil的slice中对的元素,或者在未初始化的slice中进行索引操作,nil slice不会引发panic,直接返回零值。

发送panic,进行初始化与检查

39、cookie与session的区别与应用

特性cookiesession
存储位置存储在客户端(浏览器)存储在服务器端
存储内容用户数据存储在客户端的浏览器中用户数据存储在服务器的内存或数据库中
生命周期通常由客户端设置(例如设置过期时间)默认会话过期时间由服务器设置(通常会话结束或关闭浏览器)
安全性不够安全,更容易被篡改,尤其是存储敏感数据时更安全,因为数据存储在服务器,客户端只保存一个ID
存储大小通常较小,一般限制为4kb大小由服务器限制,通常较大
传输方式每次HTTP请求都会携带Cookie每次HTTP请求只需携带一个session id
跨域限制Cookies不能跨域,但可以使用SameSite设置来增加跨域控制通常基于会话ID,session id不能跨域

cookie应用场景:

  • 记住用户登录状态
  • 跟踪用户行为
  • 跨请求持久化数据
    session应用场景:
  • 存储敏感信息
  • 用户身份证验证
  • 购物车、订单管理

40、context常见应用场景

  • 超时控制
  • 任务取消
  • 传递请求信息
  • 并发任务管理
  • 分布式系统中的请求上下文

41、开辟多个写协程向channel写数据,有序的吗?

当多个写协程向一个共享的channel写数据时,数据的顺序并不一定是有序的,具体情况取决于并发操作的执行顺序,由于go的协程是并发执行的,各个协程的执行时间和顺序不固定,因此,多个协程同时写入一个channel时,数据的顺序可能会受到调度的影响,导致输出的数据顺序不确定

42、 channel缓冲情况下接收与发送数据的流程

  • 发送操作:发送者会将数据放入缓存,如果缓存满了,发送者会被阻塞,直到有空间
  • 接收操作:接收者从缓存中取数据,如果缓存为空,接收者会被阻塞,直到有数据。

43、关闭的channel接收与发送数据会出现什么情况

从关闭的channel接收数据:
如果channel中有数据,则正常接收到数据。
如果channel已经空了,接收操作会立即返回零值,并且ok标志(第二个返回值为false),表示channel已经关闭并且没有更多的数据可接收
从关闭的channel发送数据:
如果尝试向已关闭的channel发送数据,会导致运行时panic,并且程序会崩溃,go不允许向关闭的channel发送数据,因为没有意义

44、channel底层结构分析

  • 缓冲区:对于带缓冲区的channel,底层会有一个实际存储数据的缓冲区(通常是环形缓冲区).对于无缓冲的channnel,没有实际的缓冲区,它的作用类似于一个同步队列。
  • 发送队列:存储发送数据的goroutine列表,当channel的缓冲区已满时,发送操作会被阻塞,直到有空间可用为止
  • 接收队列:存储接收数据的goroutine列表,当channel为空时,接收操作会被阻塞,直到有数据可接收为止
  • 锁:为了实现对缓冲区和队列的安全访问,channel使用锁来保证并发安全,防止多个goroutine同时访问或修改channel的内部结构

channel的类型分为:
无缓冲的channel是一种同步通道。当一个goroutine发送数据到channel时,必须等待另一个goroutine接收数据。这种类型的通道没有内部的缓冲区。
有缓冲通道:有缓冲的channel在创建时会指定一个容量,数据会被存储在一个缓冲区中。发送数据到一个已满的有缓冲通道时,发送操作会阻塞,直到有空余空间。接收数据会在缓冲区中有数据时执行。
channel的操作:发送与接收
发送操作:发送数据时,go语言会检查channel是否有足够的空间。对于无缓冲的channel,发送操作会阻塞直到有接收者。如果有缓冲的channel,发送会检查缓冲区是否已满。
有缓冲区的channel: 数据被存储在缓冲区内,发送者会等待直到有足够的空间。
无缓冲的channel 发送操作会阻塞,直到接收者能够接受数据.
接收操作:接收数据时,如果channel中没有数据且没有发送者,接收操作会阻塞。对于有缓冲的channel,接收者会从缓冲区中获取数据

. Channel 的阻塞与同步
Go 语言的 channel 是基于 goroutines 阻塞的原理来工作的。发送和接收操作通常是阻塞式的,意味着当没有足够的资源(空间或数据)时,执行这些操作的 goroutine 会被挂起,直到条件满足。

无缓冲的 channel:
当发送操作发生时,发送者会阻塞,直到有接收者。
当接收操作发生时,接收者会阻塞,直到有发送者发送数据。
有缓冲的 channel:
当发送操作发生时,如果缓冲区已满,发送者会阻塞,直到有空间可用。
当接收操作发生时,如果缓冲区为空,接收者会阻塞,直到有数据。
Channel 的关闭
当一个 channel 被关闭时,任何尝试向这个 channel 发送数据都会触发 panic。关闭的 channel 仍然可以被接收数据,但是接收的数据会返回 zero 值,并且 ok 标志为 false,表示 channel 已经关闭。

关闭操作: 通过 close(ch) 来关闭一个 channel。
接收关闭的 channel: 接收操作会返回数据,直到缓冲区为空,然后返回零值和 false。

45、面向对象的三大核心概念与五大核心准则

  • 封装:将数据和操作数据的方法绑定在一起,对外部隐藏对象的内部实现细节,只提供一些公共的访问方法来操作这些数据,从而保证了数据的安全性和完整性,防止外部随意访问和修改对象的内部状态
  • 继承:允许创建一个新的类(子类或派生类)从现有的类(父类或基类)继承属性和方法,子类可以在继承父类的基础上添加新的属性和方法,或者重写父类的方法以满足特定的需求,从而实现代码的复用和扩展
  • 多态:指不同的对象和同一消息或方法调用产生不同的响应或行为,多态性使得代码更加灵活和可维护,增强了程序的可扩展性和维护性。

面向对象的五大原则
单一职责:一个类只有一个引起它的变化的原因
开闭原则:软件实体应该对扩展开发,对修改关闭
里氏替换原则:子类必须能够替换掉它们的父类型,即派生类对象可以在程序中代替基类对象是用,且不会产生错误或异常行为。
依赖倒置原则:高层模块不应该依赖底层模块,二者都应该依赖抽象,抽象不应该依赖细节,细节应该依赖抽象
接口隔离原则:客户端不应该被迫于依赖它不使用的方法,一个类对另一个类的依赖应该建立在最小的接口上

46、进程、线程、协程、go协程的区别

特性进程线程协程go协程
定义操作系统分配资源的最小单位进程中的执行单位用户级线程、轻量级执行单元go语言中轻量级执行单元
资源占用高,独立的内存空间低,共享进程的资源极低,共享线程的资源极低、共享线程的资源
调度操作系统内核调度操作系统内核调度用户空间调度Go运行时调度
创建开销高,涉及内存分配和进程管理低,比进程创建开销小极低,创建销毁开销小极低由Go运行时管理
通信通过IPC,复杂且开销大通过共享内存或锁机制通信共享内存或消息传递,效率较高使用channel,简洁且高效
适用场景适用于需要隔离的任务适用于并发计算密集型任务适用于高并发轻量任务适用于高并发、IO密集星任务

47、select核心机制与使用场景分析

  • 非阻塞
  • 随机选择
  • 多个操作
  • default分支

场景:

  • 超时处理
  • 并发任务处理
  • 并发通信与同步
  • 多路复用
  • 处理错误与结果返回

48、go语言实现一个线程池

//肯定要冲键值offer,既然巅峰留不住,那就重走来时路

package main

import (
	"fmt"
	"sync"
	"time"
)

type Task func()

type GoroutinePool struct {
	workerCount int
	taskQueue   chan Task
	wg          sync.WaitGroup
}

func NowGoroutinePool(workerCount, queueSize int) *GoroutinePool {
	return &GoroutinePool{
		workerCount: workerCount,
		taskQueue:   make(chan Task, queueSize),
	}
}

func (gp *GoroutinePool) Start() {
	for i := 0; i < gp.workerCount; i++ {
		go gp.worker(i)
	}
}

func (gp *GoroutinePool) worker(workerId int) {
	for task := range gp.taskQueue {
		task()
		gp.wg.Done()
	}
}

func (gp *GoroutinePool) Submit(task Task) {
	gp.wg.Add(1)
	gp.taskQueue <- task
}

func (gp *GoroutinePool) Wait() {
	gp.wg.Wait()
}

func createTask(taskID int) Task {
	return func() {
		fmt.Printf("Task %d is starting...\n", taskID)
		time.Sleep(1 * time.Second)
		fmt.Printf("task %d is completed.\n", taskID)
	}
}

func main() {
	pool := NowGoroutinePool(3, 5)
	pool.Start()
	for i := 0; i < 10; i++ {
		task := createTask(i)
		pool.Submit(task)
	}
	pool.Wait()
	fmt.Println("All tasks finished.")
}

49、new与make的区别

new

  • 用于分配内存,并返回指向类型的指针
  • 分配的是基本类型的内存或自定义结构体类型
  • 只返回指针,不初始化具体的值

make:

  • 用于初始化并返回内置类型的数据结构(切片,映射,通道)
  • 返回的是初始化好的数据结构对象,不是指针
  • 需要传入相关的长度、容量等参数

50、go当中同步锁有什么特点?作用是什么?

当一个Goroutine(协程)获得了Mutex后,其他Goroutine(协程)就只能乖乖的等待,除非该Goroutine释放了该Mutex,RWMutex在读锁占用的情况下,会阻止写,但不阻止读,在写锁占用情况下,会阻止任何其他Goroutine(无论读和写)进来,整个锁相当于由该Goroutine独占同步锁的作用是保证资源在使用时的独有性,不会因为并发而导致数据错乱,保证系统的确定性

51、如果在匿名函数内panic了,在匿名函数外的defer是否会触发panic-recover?反之在匿名函数外触发panic,是否会触发匿名函数内的panic-recover?

情况一:匿名函数内panic,外部defer是否能捕获
当你在匿名函数内部panic时,匿名函数外部的defer是不能捕获这个panic的,因为panic会传播到调用栈的上一层,直到找到一个recover进行处理。如果匿名函数外部没有明确的defer或recover来捕获该panic,这个panic会直接导致程序崩溃
情况二:匿名函数外部panic,匿名函数内的defer是否能捕获?
如果panic在匿名函数外部触发,匿名函数内的defer是能够捕获的,因为defer是在函数退出时执行的,所以当匿名函数的调用栈返回时,defer语句会被执行。

52、读写锁的基本原理

读锁:

  • 多个并发读操作:当一个goroutine获取了读锁时,其他goroutine也可以获取读锁。这样多个goroutine可以并行地读取资源,而不需要互相等待。
  • 阻塞写操作:如果有任何goroutine持有读锁,写锁请求会被阻塞,直到所有读锁都释放。

写锁

  • 独占:当一个goroutine获取了写锁时,其他所有的读锁和写锁都会被阻塞,只有持有写锁的goroutine能够访问资源。
  • 优先阻塞读操作:写锁会阻塞所有新的读锁请求,直到它完成,即使有多个goroutine试图获取读锁,写锁仍然会优先执行,确保写入的独占性

53、互斥锁基本原理

Lock() :
当一个goroutine调用Lock() 时,它会尝试获取锁,如果没有其他goroutine已经持有该锁,Lock() 会成功并获取锁。如果其他goroutine已经持有该锁,调用Lock() 的goroutine将会被阻塞,直到锁被释放
Unlock() :
当一个goroutine完成对共享资源的操作后,它会调用Unlock()来释放锁,这样其他被阻塞的goroutine就有机会获取锁并访问共享资源。

54、REST API 详细规范

  • 资源命名
  • HTTP方法使用:GET/POST/DELETE/PUT/PATCH
  • 状态码
  • 响应格式
  • 版本控制
  • 错误处理

55、go并发原语

goroutine

  • 并发任务处理:http服务器,为每个请求启动一个goroutine进行任务处理
  • 异步调用:主流程对调用结果不关心的情况下,可以通过goroutine来模拟异步调用
  • 后台任务:如后台定时任务,定期执行某些操作
  • 并行计算:将一个大任务拆分成若干个小任务并行执行,加快整体计算速度

channel

  • 协程间通信:不同协程间传递数据,实现数据的共享与交换,通过channel向多个goroutine分发任务或者从多个goroutine收集处理结果
  • 并发控制:通过有限容量的channel控制并发协程数量,等待多个协程完成任务
  • 事件通知:通过channel的关闭广播,来通知多个相关的协程执行退出动作;select配合多个channel可以监听多个通道的事件,实现IO多路复用

select

  • select语句用于处理一个或多个channel的发送和接收操作,能够实现非阻塞的机制

sync包

  • WaitGroup:等待一组协程完成工作
  • Cond:通知满足条件的goroutine继续执行
  • Mutex:互斥锁,RWMutex读写锁
  • Once多协程并发场景下,仅执行一次
  • atomic确保共享变量的原子性和线程安全
  • sync.Map线程安全集合,适用于高并发场景

56、go使用什么类型?

  • method
  • bool
  • string
  • Array
  • slice
  • struct
  • pointer
  • function
  • interface
  • map
  • channel

57、语言结构

  • 关键字
  • 包声明
  • 注释
  • 函数
  • 变量和常量声明
  • 类型
  • 语句:赋值语句/条件语句/循环语句/跳转语句
  • 运算符:算术运算符/逻辑运算符/位运算符

58、go数据类型

  • 布尔型–bool
  • 数字类型–uint/int/float32/float64/byte/rune
  • 字符串类型–string
  • 复合类型:数组类型array/切片类型slice/字典类型map/管道类型channel/结构化类型struct
  • 指针类型:pointer
  • 接口类型:interface
  • 函数类型:func
  • 方法类型method

59、函数与方法的区别

函数是指不属于任何结构体、类型的方法,也就是说类型没有接收者的,而方法是有接收者的。

60、go函数返回局部变量的指针是否安全?

一般来说,局部变量会在函数返回后被销毁,因此被返回的引用就成为了“无所指”的引用,程序会进入未知状态。但在go中是安全的,go编译器会对每个局部变量进行逃逸分析,如果发现局部变量的作用域超出该函数,则不会将内存分配在栈上,即使释放函数,其内容也不会受影响。

61、go函数参数传递到底是值传递还是引用传递

go语言中所有传参都是值传递(传值),都是一个副本,一个拷贝。
参数如果是非引用类型(int,string,struct等这些),这样就在函数中就无法修改原视频内容数据:如果是引用类型(指针,map,slice,channel等这些)
值传递:将实参的值传递给形参,形参是实参得一分拷贝,实参和形参的内存地址不同,函数内对形参值内容的修改,是否会影响实参的之内容,取决于实参是否是引用类型
引用传递:将实参的地址传递给形参,函数内对形参值内容的修改,就会影响实参的值内容,
int类型:形参和实际参数内存地址不一样,证明是值传递,参数是值类型,所以函数内对形参的修改,不会修改原内容数据
指针类型:形参和实际参数内存地址不一样,证明是值传递,由于形参和实参是指针,指向同一个变量,函数内对指针指向变量的修改,会修改原内容数据。

62、defer关键字实现原理

defer能够让我们推迟执行某函数调用,推迟到当前函数返回前才实际执行,defer与panic和recoverj结合,形成了go语言风格的异常与捕获机制,如:文件句柄关闭,连接关闭,释放锁

  • 函数退出前,按照先进后出顺序,执行defer函数
  • panic后的defer函数不会被执行,遇到panic,如果没有捕获错误,函数会立刻终止
  • panic没有被recover时,抛出的panic到当前goroutine最上层函数时,最上层程序直接异常终止

63、go内置函数make和new的区别

make和new时内置函数,不是关键字,变量初始化,一般包括2步,变量声明+变量内存分配,var 关键字就是用来声明变量的,make和new函数主要用来分配内存的。make只能用来分配及初始化类型为slice、map、chan的数据
new可以分配任意类型的数据,并且置零
返回值区别:
make函数原型如下,返回的是slice、map、chan类型本身
这3种类型是引用类型,就没必要返回他们的指针

64、go slice的底层实现原理

切片是基于数组实现的,它的底层是数组,可以理解为对,底层数组的抽象。slice占用24个字节

  • array:指向底层数组的指针,占用8个字节
  • len:切片的长度,占用8个字节
  • cap:切片的容量,cap总是大于等于len的,占用8个字节

65、go array和slice的区别

  • 数组长度不同:数组初始化必须指定长度,并且长度就是固定的。切片的长度是不固定的,可以追加元素,在追加时可能使切片的容量增大。
  • 函数传参不同:数组是值类型,将一个数组赋值给另一个数组时,传递的是一份深拷贝,函数传参操作都会复制整个数组数据,会占用额外的内存,函数内对数组元素值的修改,不会修改原数组内容。切片是引用类型,将一个切片赋值给另外一个切片时,传递的是一份浅拷贝,哈数传参操作不会拷贝整个切片,只会复制len和cap,底层共用同一个数组,不会占用额外的内存,函数内对数组元素值的修改,会修改原数组内容。
  • 计算数组长度方式不同:数组需要遍历计算数组长度,时间复杂度O(n),切片包含len字段,可以通过len()计算切片长度,时间复杂度O(1)

66、slice深拷贝与浅拷贝

深拷贝方式:
1、copy(slice2,slice1)
2、遍历append赋值
浅拷贝
引用类型的变量,默认复制操作就是浅拷贝

slice2 := slice1

67、slice扩容机制

扩容会发生在slice append的时候,slice的cap不足以容纳新元素,就会进行扩容,扩容规则如下:

  • 如果新申请容量比两倍原有容量大,那么扩容后容量大小为新申请容量
  • 如果原有slice长度小于1024,那么每次就扩容为原来的2倍
  • 如果原slice长度大于等于1024,那么每次扩容为原来的1.25倍

68、slice为什么不是线程安全的?

多个线程访问同一个对象时,调用这个对象行为都可以获得正确结果,那么这个对象就是线程安全的。若有多个线程同样执行写操作,一般都需要考虑线程同步,否则可能影响线程安全。go实现线程安全的几种方式

  • 互斥锁
  • 读写锁
  • 原子操作
  • sync.once
  • sync.atomic
  • channel

slice底层结构并没有使用加锁等方式,不支持并发读写,所以并不是线程安全,使用多个goroutine对类型为slice的变量进行操作,每次输出的值大概率都不会一样,与预期值不一致;slice在并发执行中不会报错,但是数据会丢失。

69、map遍历为什么是无序的?

  • map在遍历时,并不是从固定的0号bucket开始遍历,每次遍历,都会从一个随机值序号的bucket,再从其中的随机call开始遍历
  • map遍历时,是按顺序遍历bucket,同时按需遍历bucket中和其overflow bucket中的call,但是map在扩容后,会发生key的搬迁这造成原来在一个bucket中key,搬迁后,有可能落到其他bucket中了,从这个角度看,遍历map结果就不可能是按照原来的顺序了。

70、map为什么是非线程安全的?

map默认是并发不安全的,同时为map进行并发读写时,程序会panic,
方法1:使用读写锁map + sync.RWMutex

func main() {
	var lock sync.RWMutex
	s := make(map[int]int)
	for i := 0; i < 100; i++ {
		go func(i int) {
			lock.Lock()
			s[i] = i
			lock.Unlock()
		}(i)
	}
	for i := 0; i < 100; i++ {
		go func(i int) {
			lock.RLock()
			fmt.Printf("map第%d个元素值是%d\n", i, s[i])
			lock.RUnlock()
		}(i)
	}
	time.Sleep(time.Second * 2)

}

方法二:使用Go提供的sync.Map

package main

import (
	"fmt"
	"sync"
	"time"
)

func main() {
	var m sync.Map
	for i := 0; i < 100; i++ {
		go func(i int) {
			m.Store(i, i)
		}(i)
	}
	for i := 0; i < 100; i++ {
		go func(i int) {
			v, ok := m.Load(i)
			fmt.Printf("Load:%v, %v\n", v, ok)
		}(i)
	}
	time.Sleep(time.Second)
}

71、mapp如何查找

go语言中读取map有两种语法:带comma和不带comma,当要查询的key不在map里,带comma的用法会返回一个bool型变量提示key是否在map中;而不带comma的语句则返回一个value类型的零值。如果value是int类型就返回0,如果value是string类型,就会返回空字符串。

//不带comma用法
value := m["name"]
fmt.Printf("value:%s",value)
//带comma用法
value,ok := m["name"]
if ok {
	fmt.Printf("value:%s",value)
}

在这里插入图片描述
在这里插入图片描述

1、写保护检测
函数首先会检查map的标志位flags。如果flags的写标志位此时被置1了,说明有其他协程在执行“写”操作,进而导致程序panic,这也说明了map不是线程安全的

if h.flags & hasWritting != 0 {
	throw("concurrent map read and map write")
}

2、计算hash值

hash := t.hasher(key,uintptr(h.hash0))

key经过哈希函数计算后,得到的哈希值如下(主流64位机下共64个bit位),不同类型的key会有不同的hash函数

1001011 | 00001111 | 01010

3、找到hash对应的bucket
bucket定位:哈希值的低B个bit位,用来定位key所存放的bucket
如果当前正在扩容中,并且定位到的旧bucket数据还未完成迁移,则使用旧的bucket(扩容前的bucket)
4、遍历bucket查找
tophash值定位:哈希值的高8个bit位,用来判断key是否已在当前bucket中(如果不在的话,需要去bucket的overflow中查找)
用步骤2中的hash值,得到高8个bit位,也就是10010111,转化为十进制,也就是151

top := tophash(hash)
func tophash(hash uintptr) uint8 {
	top := uint8(hash >> (goarch.PtrSize*8-8))
	if top < minTopHash {
		top += minTopHash
	}
	return top
}

上面函数中hash是64位的,sys.PtrSize是8,所以top := uint8(hash >> (sys.PtrSize*8-8))等效top = uint8(hash >> 56) 最后top取出来的值就是hash高8位值。
在bucket及bucket的overflow中寻找tophash值中为151的槽位即为key所在位置,找到了空槽位或者2号槽位,这样整个查找过程就结束了。
5、返回key对应的指针
如果通过上面的步骤找到了key对应的槽位下标i,我们再详细分析下key/value值是如何获取的:

dataOffset = unsafe.Offsetof(struct {
	b bmap
	v int64
}{},v)
bucketCnt = 8
k := add(unsafe.Pointer(b),dataOffset*i*uintptr(t.keysize))
v := add(unsafe.Pointer(b),dataOffset+bucketCnt*uintptr(t.keysize)+i*uintptr(t.valuesize))

bucket里keys的起始地址就是unsafe.Pointer(b) + dataOffset
第i下标key的地址就要在此基础上跨过i个key的大小
而我们有知道,value的地址是在所有key之后,因此第i个下标value的地址还需要加上所有key的偏移

72、map冲突的解决方式?

链地址法:
当哈希冲突发生时,创建新单元,并将新单元添加到冲突单元所在链表的尾部
开放寻址法
当哈希冲突发生时,从发生冲突的那个单元起,按照一定的次序,从哈希表中寻找一个空闲的单元,然后把发生冲突的元素存入到该单元,开放寻址法需要的表长度要大于等于所需要存放的元素数量
开放寻址法有多种方式:线性探测法、平方探测法、随机探测法和双重哈希法,这里以线性探测法
Hash(key)表示关键字key的哈希值,表示哈希值的槽位数(哈希表的大小)
线性探测法则可以表示为:

  • 如果Hash(x)%M已经有数据,则尝试(Hash(x) + 1) %M
  • 如果Hash(x + 1)%M已经有数据,则尝试(Hash(x) + 2) %M
  • 如果Hash(x + 2)%M已经有数据,则尝试(Hash(x) + 3) %M

两种解决方案比较:
对于链地址法,基于数组+链表进行存储,链表节点可以在需要时再创建,不必像开放寻址法那样事先申请好足够内存,因此链地址法对于内存利用率会比开放寻址法高。链地址法对于装载因子的容忍度会比较高,并且会存储大对象、大数据量的哈希表。而且相较于开放寻址法,它更加灵活,支持更多的优化策略,比如可采用红黑树代替链表。但是链地址法需要额外的空间来存储指针。
对于开放寻址法,它只有数组一种数据结构就可完成存储,继承了数组的优点,对CPU缓存友好,易于序列化操作。但是它对内存的利用率不如链地址法,且发生冲突时代价更高,当数据量明确,装载因子小,适合采用开放寻址法
总结:
在发生哈希冲突时,go map采用链地址法解决冲突,具体就是插入key到map中时,当key定位到桶填满8个元素后(这里的单元就是桶,不是元素),将会创建一个溢出桶,并且将溢出桶插入当前桶所在链表尾部。

73、go map的负载因子为什么是6.5

负载因子,用于衡量当前哈希表中空间占用率的核心指标。也就是每个bucket桶存储的平均元素个数。

负载因子 =  哈希表存储的元素个数/桶个数

另外负载因子与扩容、迁移等重新散列行为有直接关系:

  • 在程序运行时,会不断的进行插入、删除等,会导致bucket不均,内存利用率低,需要迁移
  • 在程序运行时,出现负载因子过大,需要做扩容,解决bucket过大的问题。

负载因子是哈希表中的一个重要指标,在各种版本的哈希表实现中都有类似的东西,主要目的是为了平衡buckets的存储空间大小和查找元素的性能高低。
在接触各种哈希表都可以关注一下,做不同的对比,看着各家的考量。

loadFactor%overflowbytes/entryhitprobemissprobe
4.002.1320.773.004.00
4.504.0517.303.254.50
5.006.8514.773.505.00
5.5010.5512.943.755.50
6.0015.2711.674.006.00
6.5020.9010.794.256.50
7.0027.1410.154.507.00
7.5034.039.734.757.50
8.0041.109.405.008.00
  • loadFactor:负载因子,也有叫装载因子
  • %overflow:溢出率,有溢出率bucket的百分比
  • bytes/entry:平均每对key/value的开销字节数
  • hitprobbe:查找一个不存在的key时,要查找的平均个数
  • missprobe:查找一个不存在的key时,要查找的平均个数

装载因子越大,填入元素越多,空间利用率越高,但发生哈希几率变大,反之,装载因子越小,填入的元素越少,空间浪费也会变得越多,而且还会提高扩容操作的次数。因此定了个6.5

74、map如何扩容

在向map插入新key的时候,会进行条件检测,符合下面2个条件,会触发扩容
条件1:超过负载
ma元素个数 > 6.5 * 桶个数
条件2: 溢出桶太多
当桶总数 < 2 ^ 15 时,如果溢出桶总数 >= 桶总数,则认为溢出桶过多
当桶总数 >= 2 ^ 15时,直接与2^15比较,当溢出桶总数 >= 2^15时,溢出桶太多了。

对于条件2:其实算是对条件1的补充。因为在负载因子比较小的情况下,有可能map查找和插入效率很低。
表面来看就是负载因子比较小,map元素总数少,但是桶数量多。比如不断地增删,这样会造成overflow的bucket数量增多,但是负载因子又不高,达不到的第1点的临界值,就不能触发扩容来缓解这种情况。这样会造成桶的使用率不高,值存储的比较稀疏,查找插入效率会变得非常低,因此有了第2扩容条件。
扩容机制:
双倍扩容:针对条件1,新建一个buckets数组,新的buckets大小是原来的2倍,然后旧buckets数据搬迁到新的buckets,该方法我们称之为双倍扩容
等量扩容:针对条件2,并不扩大容量,buckets数量维持不变,重新做一遍类似双倍扩容的搬迁动作,把松散的键值对重新排列一次,使得同一个bucket中的key排列地更紧密,节省空间,提供bucet利用率,进而保证更快地存取,该方法我们称之为等量扩容

75、map和sync.Map谁的性能好,为什么?

go语言sync.Map支持并发读写,采取了“空间换时间”的机制,

type Map struct {
	mu Mutex
	read atomic.Value
	dirty map[interface{}]*entry
	misses int
}

对比原始的map:
和原始map+RWLock的实现并发方式相比,减少了加锁对性能的影响,他做了一些优化,可以无锁访问read map,而且会优先操作read map,倘若只操作read map就可以满足要求,那就不用去操作write map(dirty),所以在某些特定场景中它发生锁竞争的频率会远远小于map+RWLock的实现方式。
优点:
适合读多写少的场景
缺点:
写多的场景,会导致read map缓存失败,需要加锁,冲突变多,性能急剧下降。

76、channel的底层实现原理

使用make(chan T,cap)来创建channel,make语法会在编译时,转换为makechan64和makechan
创建会做一些检查:

  • 元素大小不能超过64k
  • 元素对齐大小不能超过maxAlign也就是8字节
  • 计算出来的内存是否超过限制

创建时

  • 如果是无缓存的channel,会直接给hchan分配内存
  • 如果是有缓存的channel,并且元素不包含指针,那么会为hchan和底层数组分配一段连续的地址
  • 如果是有缓存的channel,并且元素包含指针,那么会为hchan和底层数组分别分配地址

阻塞式:
调用chansend函数,并且block=true

ch <- 10

非阻塞式:

select {
	case ch <- 10:
	...
	default
}

向channel发送数据时大概分为两大块,检查和数据发送,数据发送流程如下:

  • 如果channel的读等待队列存在接收者goroutine,将数据直接发送给第一个等待的goroutine,唤醒接收的goroutine
  • 如果channel的等待队列不存在接收者goroutine;如果循环数组buf来满,那么将会把数据发送到循环数组buf的队尾;如果循环数组buf已满,这个时候会走阻塞发送流程,将当前goroutine加入等待队列,并挂起等待唤醒。

接收:
发送操作,编译时转换为runtime.chanrecv函数
阻塞式:
调用chanrecv函数,并且block=true

<ch
v := ch
v,ok := <ch
for i := range ch {
	fmt.Println(i)
}

非阻塞式
调用chanrecv函数,并且block=true

select {
	case <- ch:
	....
	default
}

向channel中接收数据时大概分为两大块,检查和数据发送,而数据接收流程如下:

  • 如果channel的写等待队列存在发送者goroutine,如果是无缓冲channel,直接从第一个发送者goroutine那里把数据拷贝给接受变量,唤醒发送的goroutine;如果是有缓冲的channel(已满),将循环数组buf的队首元素拷贝给接收变量,将第一个发送者goroutine的数据拷贝到buf循环数组队尾,唤醒发送的goroutine。
  • 如果channel的写等待队列不存在发送者goroutine,如果循环数组buf非空,将循环数组buf的队首元素拷贝给接收变量;如果循环数组buf为空,这个时候就会走阻塞接收的流程,将当前goroutine加入等待队列,并挂起等待唤醒。

关闭:
调用close函数,编译时转换为runtime.closeChan函数

77、channel有什么特点

channel有2种类型:无缓冲,有缓冲
channel有3种模式,写操作模式、读操作模式、读写操作模式

写操作模式读操作模式读写操作模式
创建make(chan <- int)make(<-chan int)make(chan int)

channel有3种状态:初始化、正常、关闭

未初始化关闭正常
关闭panicpanic正常关闭
发送永远阻塞导致死锁panic阻塞或者成功发送
接收永远阻塞导致死锁缓冲区为空则为零值,否则可以继续读阻塞或者成功接收

注意点:

  • 一个channel不能多次关闭,会导致panic
  • 如果多个goroutine都监听同一个channel,那么channel上的数据都可能随机某一个goroutine取走进行消费
  • 如果多个goroutine监听同一个channel,如果这个channel被关闭,则所有goroutine都能收到退出信号

78、go channel有无缓冲的区别

  • 无缓冲:一个送信人去你家送信,你不在家他不走,你一定要接下信,他才会走
  • 有缓冲:一个送信人去你家送信,扔到你家的信箱转身就走,除非你的信箱满了,他必须等信箱有多余空间才会走
无缓冲有缓冲
创建方式make(chan TYPE)make(chan TYPE,SIZE)
发送阻塞数据接收前发送阻塞缓冲满时发送阻塞
接收阻塞数据发送前接收阻塞缓冲空时接收阻塞

79、channel为什么设计成线程安全

不同协程通过channel进行通信,本身的使用场景就是多线程,为了保证数据的一致性,必须实现线程安全
如何实现线程安全的
channel的底层实现中,hchan结构体中采用mutex锁来保证数据读写安全,在对循环数据buf中的数据进行入队和出队操作时,必须先获取互斥锁,才能操作channel

80、channel如何控制goroutine并发执行顺序

使用channel进行通信通知,用channel去传递信息,从而控制并发执行顺序

81、go channel发送和接收什么情况下会死锁

死锁:

  • 单个协程永久阻塞
  • 两个或两个以上的协程执行过程中,由于竞争者或由于彼此通信而造成的一种阻塞现象

channel死锁场景:

  • 非缓存channel只写不读
  • 非缓存channel读在写后面
  • 缓存channel写入超过缓冲区数量
  • 空读
  • 多个协程互相等待

82、go互斥锁实现原理

在这里插入图片描述

在这里插入图片描述

  • 在Lock()之前使用Unlock()会导致panic异常
  • 使用Lock()加锁后,再次Lock()会导致死锁(不支持重入),需Unlock()解锁后才能在加锁
  • 锁定状态与goroutine没有关联,一个goroutine可以Lock,另一个goroutine可以Unlock

83、go互斥锁正常模式与饥饿模式

正常模式:
在刚开始的时候,是处于正常模式,也就是,当一个g1持有着一个锁的时候,g2会自旋的去尝试获取这个锁。
当自旋超过4次,还没有能获取到锁的时候,这个g2就会被加入到获取锁的等待队列里面,并阻塞等待唤醒

正常模式下,所有等待锁的goroutine按照FIFO(先进先出)顺序等待,唤醒的goroutine,不会直接拥有锁,而是和
新请求锁的goroutine竞争锁,新请求锁的ggoroutine具有优势,它正在CPU上执行,而且可能有好几个,
所以刚刚唤醒的goroutine有很大可能在竞争中失败,长时间获取不到锁,就会切换到饥饿模式

饥饿模式
当一个goroutine等待锁时间超过1毫秒时,他可能会遇到饥饿问题,在版本1.9中,这种场景下go mutex切换到饥饿模式,解决饥饿模式

饥饿模式下,直接把锁交给等待队列中排在第一位的goroutine(队头),同时饥饿模式下,新进来的goroutine不会参与抢锁也不会进入自旋状态,会直接进入等待队列的尾部,这样很好的解决老的goroutine一直抢不到锁的场景

那么也不可能说永远的保持一个饥饿状态,总归会有吃饱的时候,也就是总有那么一刻Mutex会回归到正常模式,那么回归正常模式必须具备的条件有以下几种。

  • G的执行时间小于1ms
  • 等待队列全部清空

当满足上述两个条件任意一个的时候,Mutex会切换到正常模式,而Go的抢锁的过程,就是在这个正常模式和饥饿模式中来回切换进行的。

84、go互斥锁允许自旋的条件

线程没有获取到锁常见有2种处理方式:

  • 一种是没有获取到锁的线程就一直循环等待判断该资源是否已经释放锁,这种锁也叫做自旋锁,他不用将线程阻塞起来,适用于并发低且程序执行时间短的场景,cpu占用鲛高
  • 另外一种处理方式就是把自己阻塞起来,会释放cpu给其他线程,内核会将线程置为睡眠状态,等到锁被释放后,内核会在合适的时机唤醒该线程,适用于高并发场景,缺点是有线程上下文切换的开销

允许自旋的条件:

  • 锁已被占用,并且锁不处于解饿模式
  • 积累的自旋次数小于最大自旋次数
  • cpu核数大于1
  • 有空闲的p
  • 当前goroutine所挂载的p下,本地待运行队列为空。

85、go 读写锁的实现原理

读写互斥锁RWMutex,是对Mutex的一个扩展,当一个goroutine获得了读锁后,其他的goroutine可以获取读锁,但不能获取写锁;当一个goroutine获得了写锁后,其他的goroutine即不能获取读锁也不能获取写锁
使用场景:
读多于写的情况
底层实现结构

type RWMutex struct {
	w Mutex //复用互斥锁
	writeSem uint32 //信号量,用于写等待读
	readerSem uint32 //信号量。用于读等代写
	readerCount int32 //当前执行读的goroutine数量
	readerwait int32 //被阻塞准备读的goroutine的数量
}

注意点:

  • 读锁或写锁在Lock()之前使用Unlock()会导致panic异常
  • 使用Lock()加锁后,再次Lock()会导致死锁,需Unlock()解锁后才能再加锁
  • 锁定状态与goroutine没有关联,一个goroutine可以RLock,另一个goroutine可以RUnlock(Unlock)

互斥锁和读写锁的区别:

  • 读写锁区分读者和写者,而互斥锁不区分
  • 互斥锁同一时间只允许一个线程访问对象,无论读写,读写锁同一时间只允许一个写者,但是允许多个读者同时读对象。

86、可重入锁

package main

import (
	"fmt"
	"sync"
	"time"
)

type ReentrantLock struct {
	mu       sync.Mutex
	owner    *goro // 当前持锁的 goroutine
	holds    int    // 锁的计数器,表示锁被同一 goroutine 拿到的次数
}

type goro struct {
	id int
}

var goroID int

// 获取当前 goroutine 的 ID
func getGoroID() *goro {
	goroID++
	return &goro{id: goroID}
}

func (r *ReentrantLock) Lock() {
	r.mu.Lock() // 先加锁
	defer r.mu.Unlock()

	current := getGoroID()

	// 如果当前 goroutine 已经持有锁,则计数器加 1
	if r.owner == current {
		r.holds++
		return
	}

	// 否则设置为当前 goroutine 为持锁者,并初始化计数器
	r.owner = current
	r.holds = 1
}

func (r *ReentrantLock) Unlock() {
	r.mu.Lock() // 先加锁
	defer r.mu.Unlock()

	// 如果当前 goroutine 不是持锁者,不能解锁
	if r.owner == nil || r.owner != getGoroID() {
		panic("unlock of unlocked lock")
	}

	r.holds--

	// 如果锁计数器为 0,表示可以释放锁
	if r.holds == 0 {
		r.owner = nil
	}
}

func main() {
	var lock ReentrantLock

	var wg sync.WaitGroup

	// 示例:一个 goroutine 中进行多次加锁和解锁
	wg.Add(1)
	go func() {
		defer wg.Done()

		// 第一次加锁
		lock.Lock()
		fmt.Println("Goroutine 1 acquired the lock first time.")

		// 第二次加锁
		lock.Lock()
		fmt.Println("Goroutine 1 acquired the lock second time.")

		// 释放锁
		lock.Unlock()
		fmt.Println("Goroutine 1 released the lock first time.")

		// 第三次加锁
		lock.Lock()
		fmt.Println("Goroutine 1 acquired the lock third time.")

		// 释放锁
		lock.Unlock()
		fmt.Println("Goroutine 1 released the lock second time.")

		// 释放锁
		lock.Unlock()
		fmt.Println("Goroutine 1 released the lock third time.")
	}()

	wg.Wait()
}

87、原子操作有哪些

  • 增减Add
  • 载入:Load
  • 比较并交换CompareAndSwap
  • 交换Swap
  • 存储:Store

88、原子操作和锁的区别

  • 原子操作由底层硬件支持,而锁是基于原子操作+信号量完成的,若实现相同的功能,前者通常会更有效率
  • 原子操作是单个指令的互斥操作:互斥锁/读写锁是一种数据结构,可以完成临界区(多个指令)互斥操作,扩大原子操作的范围
  • 原子操作是无锁操作,属于乐观锁;说起锁的时候,一般属于悲观锁
  • 原子操作存在于各个指令/语言层级,比如"机器指令层级的原子操作"“go语言层级的原子操作”等
  • 锁也存在于各个指令/语言层级中,比如机器指令层级的锁”,“汇编指令层级的锁”,“go语言层级的锁”

89、goroutine底层实现原理

type g struct {
	gold int64
	sched gobuf
	stack stack 
	gopc
	startpc
}
type gobuf struct {
	sp uintptr
	pc uintptr
	g uintptr
	ret uintptr
	
}
type stack struct {
	lo uintptr //栈的下界内存地址
	hi uintptr // 栈的上街内存地址
}

状态流转

状态含义
空闲中_GidleG刚刚新建,仍未初始化
待运行_Grunnable就绪状态,G在运行队列中,等待M取出并运行
运行中_GrunningM正在运行这个G,这时候M会拥有一个P
系统调用中_GsyscallM正在运行这个G发起的系统调用,这时候M并不拥有P
等待中_GwaitingG在等待某些条件完成,这时候G不在运行也不再运行队列中(可能在channel等待队列中)
已中止_GdeadG未被使用,可能已执行完毕
栈复制中_GcopystackG正在获取一个新的栈空间并把原来的内容复制过去(用来防止GC扫描)

在这里插入图片描述
创建
通过go关键字调用底层函数runtime.newproc() 创建一个goroutine,当调用该函数之后,goroutine会被设置成runnable状态

func main() {
	go func() {
		fmt.Println("func routine")
	}()
	fmt.Println("main goroutine")
}

创建好的这个goroutine会新建一个自己的栈空间,同时在G的sched中维护栈地址与程序计数器这些信息。
每个G在被创建之后,都会被优先放入到本地队列中,如果本地队列已经满了,就会被放入到全局队列中。
运行
goroutine本身只是一个数据结构,真正让goroutine运行起来的是调度器,Go实现了一个用户态的调度器,这个调度器充分利用现代计算机的多核特性,同时让多个goroutine运行,同时goroutine设计的轻量级别,调度和上下文切换的代价都比较小
调度时机:

  • 新起一个协程和协程执行完毕
  • 会阻塞的协同调用,比如文件io,网络io
  • channel、mutex等阻塞操作
  • time.sleep
  • 垃圾回收之后
  • 主动调用runtime
  • 运行过久或系统调用过久等等

每个M开始执行p的本地队列中的G时,goroutine会被设置成running状态
如果某个M把本地队列中的G都执行完成之后,然后就会去全局队列中拿G,这里需要注意,每次去全局队列拿G的时候,都需要上锁,避免同样的任务被多次拿。
如果全局队列都被拿完了,而当前M也没有更多的G可以执行的时候,它就会去其他P本地队列中拿任务,这个机制被称之为work stealing机制,每次会拿走一半的任务,向下取整,比如另一个p中有3个任务,那一半就是一个任务
当全局队列为空,M也没办法从其他的P中拿任务的时候,就会让自身进入自选状态,等待有新的G进来,最多只会有GOMAXPROCS个M在自旋状态,过多M的自旋会浪费CPU资源

阻塞
channel的读写操作、等待锁、等待网络数据、系统调用等都有可能发生阻塞,会调用底层函数runtime.gopark() ,会让出CPU时间片,让调度器安排其他等待的任务运行,并在下次某个时候从该位置恢复执行
当调用该函数之后,goroutine会被设置成waiting状态
唤醒
处于waiting状态的goroutine,在调用runtime.goready()函数之后会被唤醒,唤醒的goroutine会被重新放到M对应的上下文P对应的runqueue中,等待被调度
当调用该函数之后,goroutine会被设置成runnable状态
退出
当goroutine执行完成后,会调用底层函数runtime.Goexit(),当调用该函数之后,goroutine会被设置成dead状态

90、goroutine和线程的区别

goroutine线程
内存占用创建一个goroutine的栈内存消耗为2KB,时机运行过程中,如果栈空间不够用,会自动进行扩容创建一个线程的栈内存消耗为1MB
创建和销毁goroutine因为是由goroutine 负责管理的,创建和销毁的消耗非常小,是用户级线程 创建和销毁都会有巨大的消耗,因为要和操作系统打交道,是内核级的,通常解决的办法就是线程池
切换goroutines切换只需保存三个寄存器:PC、SP、BP goroutine的切换约为200ns,相当于2400-3600条指令当线程切换时,需要保存各种寄存器,以便恢复现场,线程切换会消耗1000-1500ns,相当于12000-18000条指令

91、goroutine泄露的场景

泄露原因

  • goroutine内进行channel/mutex等读写操作被一直阻塞
  • goroutine内的业务逻辑进入死循环,资源一直无法释放
  • goroutine内的业务逻辑进入长时间等待,有不断新增的goroutine进入等待

泄露场景

  • nil channel
  • 发送不接收
  • 接收不发送
  • http request body未关闭
  • 互斥锁忘记解锁
  • sync.WaitGroup不当:add与Done不匹配

92、如何查看正在执行的goroutine数量

go tool pprof -http=:1248 http://127.0.0.1:6060/debug/pprof/goroutine

93、go线程实现模型

go实现的是两级线程模型(M:N),准确的说是GMP模型,是对两级线程模型的改进实现,使它能够更加灵活地进行线程之间的调度

含义缺点
单进程时代每个程序就是一个进程,直到一个程序运行完,才能进行下一个进程无法并行,只能串行;进程阻塞所带来的CPU时间浪费
多进程/线程时代一个线程阻塞,cpu可以立刻切换到其他线程中去执行进程/线程占用内存高2.进程/线程上下文切换成本高
协程时代协程(用户态线程)绑定线程(内核态线程)cpu调度线程执行实现起来较复杂,协程和线程的绑定依赖调度器算法

三种线程模型:

  • 内核级线程模型 (1:1)
  • 用户级线程模型(N:1)
  • 两级线程模型

M:N
优点:

  • 能够利用多核
  • 上下文切换成本低
  • 如果进程中的一个线程被阻塞,不会阻塞其他线程,是能够切换同一个进程内的其他线程继续执行

缺点:

  • 实现起来比较复杂

94、GMP和GM模型

GMP结构

  • G(Goroutine):代表Go协程Goroutine,存储了 Goroutine的执行栈信息、Goroutine 状态以及 Goroutine 的任务函数等。G的数量无限制,理论上只受内存的影响,创建一个G的初始栈大小为2-4K,配置一般的机器也能简简单单开启数十万个Goroutine,而且Go语言在G退出的时候还会把G清理之后放到P本地或者全局的闲置列表 gFree中以便复用。
  • M(Machine): Go对操作系统线程(OSthread)的封装,可以看作操作系统内核线程,想要在 CPU 上执行代码必须有线程,通过系统调用 cone 创建。M在绑定有效的P后,进入一个调度循环,而调度循环的机制大致是从P的本地运行队列以及全局队列中获取G,切换到G的执行栈上并执行G的函数,调用goexit 做清理工作并回到M,如此反复。M并不保留心状态,这是G可以跨M 调度的基础。M的数量有限制,默认数量限制是10000,可 以通过 debug.SetMaxThreads()方法进行设置,如果有M空闲,那么就会回收或者睡眠。
  • *P(Processor):虚拟处理器,M执行G所需要的资源和上下文,只有将P和M绑定,才能让P的runq 中的G 真正运行起来。P的数量决定了系统内最大可并行的G的数量,**P的数量受本机的CPU核数影响,可通过环境变 量$GOMAXPROCS或在runtime.GOMAXPROCS()来设置,默认为CPU核心数。
  • Sched:调度结构,他维护有存储M和G的全局队列,以及调度器的一些状态信息
GMP
数量限制无限制、受机器内存影响有限制,默认最多1w有限制,最多GOMAXPROCS个
创建时机go func当没有足够的M来关联p并运行其中的可运行的G时会请求创建新的M在确定了P的最大数量n后,运行时系统会根据这个数量创建个p

GM调度存在的问题:

  • 全局队列的锁竞争,当M从全局队列中添加或者获取G的时候,都需要获取队列锁,导致激烈的锁竞争
  • M转移G增加额外的开销,当M1在执行G1的时候,M1创建了G2,为了继续执行 G1,需要把G2保存到全局队列中,无法保证G2是被M1处理。因为M1原本就保存了G2的信息,所以G2最好是在M1上执行,这样的话也不需要转移G到全局队列和线程上下文切换
  • 线程使用效率不能最大化:没有work-stealing和hand-off机制

95、Go调度原理

goroutine调度的本质就是将Goroutine按照一定算法放到CPU上去执行。
CPU感知不到Goroutine,只知道内核线程。所以需要Go调度器将协程调度到内核线程上面去,然后操作系统调度器将内核线程放到CPU上去执行。
M是对内核级线程的封装,所以Go调度器的工作就是将G分配到M
Go调度器的实现不是一蹴而就的,它的调度器模型与算法也是几经演化,从最初的GM模型,到GMP模型,从不支持抢占,到支持协作抢占,再到支持基于信号的异步抢占,经历了不断地优化与打磨。
设计思想

  • 线程复用(work stealing机制和hand off机制)
  • 利用并行 (利用多核CPU)
  • 抢占调度 (解决公平性问题)

调度对象

Go调度器

Go调度器是属于Go runtime中的一部分,Go runtime负责实现Go的并发调度、垃圾回收、内存堆栈管理等关键功能

被调度对象
G的来源

  • P的runnext(只有一个G,局部性原理,永远会被最先调度执行)
  • P的本地队列(数组,最多256个G)
  • 全局G队列(l链表、无限制)
  • 网络轮询器(存放网络调用被阻塞的G)

P的来源

  • 全局P队列(数组,GOMAXPROCS个P)

M的来源

  • 休眠线程队列(未绑定P,长时间休眠会等待GC回收销毁)
  • 运行线程(绑定P,指向P中的G)
  • 自旋线程(绑定P,指向M的G0)

其中运行线程数+自旋线程数 <= P的数量(GOMAXPROCS),M个数>=P个数

G的生命周期:G从创建、保存、被获取、调度和执行、阻塞、销毁、步骤如下:

  • 创建G,关键字go func() 创建G
  • 保存G,创建G优先保存到本地队列P,如果P满了,则会平衡部分P到全部队列中,
  • 唤醒或者新建M执行任务,进入调度循环
  • M获取G,M首先从P的本地队列获取G,如果P为空,则从全局队列获取G,如果全局队列也为空,则从另一个本地队列偷取一半数量的G(负载均衡),也称之为work stealing
    在这里插入图片描述
  • M执行完G后清理现场,重新进入调度循环(将M上运行的goroutine切换为G0,G0负责调度时协程的切换)
    其中步骤2中保存G的详细流程如下:
  • 执行go fun

其中步骤2中保存G的详细流程如下:

  • 执行go func的时候,主线程M0会调用newproc()生成一个G结构体,这里会选定当前M0上的P结构
  • 每个协程G都会被尝试先放到P中的runnext,,若runnext为空则放到runnext中,生产结束
  • 若runnext满,则将原来runnext中的G踢到本地队列总,但将当前G放到runnext中,生产结束。
  • 若本地队列也满了,则将本地队列中的G拿出一半,放到全局队列中,生产结束。

在以下情形下:会切换正在执行的goroutine

  • 抢占式调度:sysmon检测到协程运行过久(sleep,死循环)
    - 切换到go,进入调度循环
  • 主动调度:新起一个协程和协程执行完毕(触发调度循环);主动调用runtime.Gosched()(切换到g0,进入调度循环);垃圾回收之后(stw之后,会重新选择g开始执行)
  • 被动调度:系统调用(比如文件io)阻塞(同步)【阻塞G和M,P与M分离,将P交给其他M绑定,其他M执行P的剩余G】;网络IO调用阻塞(异步);【阻塞G,G移动到NetPoller,M执行P的剩余G】;atomic/mutex/channel等阻塞(异步)【阻塞G,G移动到channel的等待队列中,M执行P的剩余G】

调度策略
使用什么策略来挑选下一个goroutine执行?
由于P中的G分布在runnext、本地队列、全局队列、网络轮询器中,则需要挨个判断是否有可执行的G,大体逻辑如下:

  • 要执行61次调度循环,从全局队列获取G,若有则直接返回
  • 从P上的runnext看一个是否有G,若有则直接返回
  • 从P上的本地队列看一个是否有G,若有直接返回。
  • 上面都没查找到时,则去全局队列、网络轮询器查找或者从其他P中窃取,一直阻塞直到获取到一个可用的G为止。

96、work stealing机制

当线程M无可运行的G时,尝试从其他M绑定的P偷取G,减少空转,提供了线程利用率(避免闲着不干活).
当从本线程绑定P本地队列、全局G队列、netpoller都找不到可执行的g,会从别的P里窃取G并放到当前P上面。
从netpoller中拿到G是Gwaiting状态,存放的是因为网络IO被阻塞的G,其他地方拿到G是Grunnable状态。
从全局队列的G数量:N=min(len(GRQ)/GOMAXPROCS+1,len(GRQ/2))从其他P本地队列窃取的G数量:N=len(LRQ)/2

窃取流程:
源码见runtime/proc.go stealWork函数,窃取流程如下,如果经过多次努力一直找不到需要运行的goroutine则调用stopm进入睡眠状态,等待被其他工作线程唤醒。

  • 选择要窃取的P
  • 从P中偷走一半G

选择要窃取的P
窃取的本质就是遍历allp中的所有p,查看其运行队列是否有goroutine,如果有,则取其一半到当前工作线程的运行队列。
为了保证公平性,遍历allp时并不是固定的从allp[0]即第一个p开始,而是从随机位置上的p开始,而且遍历的顺序也随机化了,并不是在访问第i个p下一次就访问第i+1个p,而是使用了一种伪随机的方式遍历allp中的每个p防止每次遍历时使用同样的顺序访问allp中的元素.
从P中偷走一半G
挑选出盗取的对象p之后,则调用runqsteal盗取p的运行队列中的goroutine,runqsteal函数再调用runqgrap从p的本地队列尾部批量偷走一半的g。

97、Go hand off机制

也称为P分离机制,当本线程M因为G进行的系统调用阻塞时,线程释放绑定的P,把P转移给其他空闲的M执行,也提高了线程利用率
分离流程
当前线程M阻塞时,释放P,给其他空闲的M处理.

98、Go 抢占式调度

协作式:大家都按事先定义好的规则来。比如:一个goroutine执行完后,退出,让出p,然后下一个goroutine被调度到p上运行。这样做的缺点就在于是否让出p的决定权在goroutine自身。一旦某个g不主动让出p或执行时间较长,那么后面的goroutine只能等着,没有方法让前者让出p,导致延迟甚至饿死。

  • 编译器会在调用函数前插入runtime.morestack,让运行时有机会在这段代码中检查是否需要执行抢占调度
  • Go语言运行时会在垃圾回收暂停程序、系统监控发现Goroutine运行超过10ms,那么会在这个协程设置一个抢占标记
  • 当发生函数调用时,可能会执行编译器插入的runtime.morestack,它调用的runtime.newstack会检查抢占标记,如果有抢占标记就会触发抢占让出cpu,切到调度主协程里

基于信号抢占调度
真正的抢占式调度是基于信号完成的,所以也称为异步独占,不管协程有没有意愿主动让出cpu运行权,只要某个协程执行时间过长,就会发送信号强行夺取cpu运行权

  • M注册一个SIGURG信号的处理函数:sighandler
  • sysmon启动后会间隔性的进行监控,最长间隔10ms,最短间隔20us,如果发现某协程独占P超过10ms,会给M发送抢占信号。
  • M收到信号后,内核执行sighandler函数把当前协程的状态从——Grunning正在执行改成_Grunnable可执行,把抢占的协程放到全局队列里,M继续寻找其他goroutine来运行
  • 被抢占的G再次调度过来执行,会继续原来的执行流。

99、go内存逃逸

go编译器判断一个变量的生命周期是否能够超出其所在的函数或栈,如果变量的生命周期超出了栈的范围,它会被分配到堆上,这种现象被称为“内存逃逸”

  • 函数返回值逃逸
  • 闭包引用外部变量
  • 使用切片时逃逸
  • 大对象逃逸

100、gc实现原理

垃圾回收过程中对象的三种状态

  • 灰色:对象还在标记队列中等待
  • 黑色:对象已被标记,gcmarkBits对应位为1
  • 白色:对象未被标记,gcmarkBits对应位为0
  1. 创建白、灰、黑三个集合
  2. 将所有对象放入白色集合中
  3. 遍历所有root对象,把遍历到的对象从白色集合放入灰色集合
  4. 遍历灰色集合,将灰色对象引用的对象从白色集合放入灰色集合,自身标记为黑色
  5. 重复步骤4,直到灰色中无任何对象:写屏障,辅助GC
  6. 收集所有白色对象

101、gc流程

一次完整的垃圾回收分为四个阶段,分别是标记准备、标记开始、标记终止、清理

  • 标记准备:打开写屏障
  • 标记开始:使用三色标记法并发表及,与用户程序并发执行
  • 标记终止:对触发写屏障的对象进行重新扫描标记,关闭写屏障
  • 清理:将需要回收的内存归还到堆中,将过多的内存归还给操作系统。

102、gc触发时机

主动触发:
调用runtime.GC()方法,触发GC

被动触发:

  • 定时触发,该触发条件由runtime.forcegcperiod变量控制,默认为2分钟,当超过两分钟没有产生任何GC时,触发GC
  • 根据内存分配阈值触发,该触发条件由环境变量GCGC控制,默认值为100,当前堆内存占用是上次GC结束后占用内存的2倍时,触发GC

103、gc如何调优

  • 控制内存分配速度,限制goroutine数量,提高赋值器mutator的cpu利用率(降低gc的cpu利用率)
  • 少量使用+连接string
  • slice提前分配足够的内存来降低扩容带来的拷贝
  • 避免map key对象过多,导致扫描时间增加
  • 变量复用,减少分配,如何使用sync.Pool来复用需要频繁创建临时对象,使用全局变量等
  • 增大GOGC的值,降低GC运行频率

104、gc常用的并发模型?

  • 共享内存:抽象层级低,耦合高,竞争会导致数据冲突
  • 发送消息:抽象层级高,耦合低,线程竞争

channel

105、Cond实现原理

go标准库提供了Cond原语,可以让Goroutine在满足特定条件时被阻塞和唤醒

type Cond struct {
	noCopy noCopy
	L Locker
	notify notifyList
	checker copyChecker
}

type notifyList struct {
	wait uint32
	notify uint32
	lock uintptr
	head unsafe.Pointer
	tail unsafe.Pointer
}
  • nocopy:go源码中检测禁止拷贝的技术,如果程序中有waitgroup的赋值行为,使用go vet检查程序时,就会发现有报错,但需要注意的是,noCopy不会影响程序正确的编译和运行
  • checker:用于禁止运行期间发生拷贝、双重拷贝
  • L:可以传入一个读写锁或互斥锁,当修改条件或者调用wait方法时需要加锁
  • notify:通知链表,调用Wait()方法的Goroutine会放到这个链表中,从这里获取需被唤醒的Goroutine列表

使用方法

  • sync.NewCond(l Locker):新建一个sync.Cond变量,注意该函数需要一个Locker作为必填参数,这是因为在cond.Wait()中底层会涉及到Locker的锁操作。
  • Cond.Wait():阻塞等待被唤醒,调用Wait函数前需要先加锁;并且由于Wait函数被唤醒时存在虚假唤醒等情况,导致唤醒后发现,条件依旧不成立,因此需要使用for语句来循环进行等待,直到条件成立为止
  • Cond.Signal() :只唤醒一个最先Wait的goroutine,可以不用加锁
  • Cond.Broadcast():唤醒所有wait的goroutine,可以不用加锁

106、go哪些方式安全读写共享变量

方式并发原语备注
不要修改变量sync.Once不要去写变量,变量只初始化一次
只允许一个goroutine访问变量Channel不要通过共享变量莱通信,而是通信来共享1变量
允许多个goroutine访问变量,但是同一个时间只允许一个goroutine访问sync.Mutex,sync.RWMutex原子操作实现锁机制,同时只有一个线程能拿到锁

107、如何盘查数据竞争

go命令行有个参数rance帮助检测代码中的数据竞争

go run -race main.go
原文地址:https://blog.csdn.net/m0_37149062/article/details/145306965
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.kler.cn/a/600666.html

相关文章:

  • 非完整移动多机器人系统的事件触发编队控制方法研究
  • 基于ssm的微博网站(全套)
  • 《基于计算机视觉的步态识别方法研究》开题报告
  • HTML5 Geolocation(地理定位)学习笔记
  • 鸿蒙学习笔记(1)-文件解读、编写程序、生命周期
  • 【学习记录】vue3中 Ref跟ref的区别?
  • Windows 10 LTSC 2019 中文版下载及安装教程(附安装包)
  • Elixir语言的测试开发
  • 安装DNS(BIND)并部署主、从域服务
  • Zotero·Awesome GPT配置
  • 后端返回了 xlsx 文件流,前端怎么下载处理
  • DIVA-GIS:一个免费的GIS分析小软件
  • 每日总结3.24
  • 如何理解 Apache Iceberg 与湖仓一体(Lakehouse)?
  • 【C++】 —— 笔试刷题day_8
  • SpringMVC 配置与应用详解
  • 《TCP/IP网络编程》学习笔记 | Chapter 22:重叠 I/O 模型
  • 基础场景-------------------(5)重载和重写的区别
  • 数据库练习
  • 笔试专题(三)