Golang底层总结

1. slice底层数据结构和扩容原理

  1. 数据结构
    Go 的 slice 底层数据结构是由一个 array 指针指向底层数组,len 表示切片长度,cap 表示切片容量。
  2. 扩容原理
    (1)扩容思路:对于 append 向 slice 添加元素时,若 slice 容量够用,则追加新元素进去,slice.len++,返回原来的 slice。当原容量不够,则 slice 先扩容,扩容之后 slice 得到新的 slice,将元素追加进新的 slice,slice.len++,返回新的 slice。
    (2)扩容规则
    1.17版本扩容:容量小于1024时候是2倍速率扩容,当容量大于等于1024时,是大于等于1.25倍速率进行扩容。扩容之后,指向底层数组的指针改变。
    1.18版本扩容:容量小于256时候是2倍速率扩容,当容量大于256时候,扩容为 (1.25倍的原容量+ 3 * 256 / 4)

2. 数组和切片的区别

在此之前,先理解下 引用指针 区别:

引用可以是一个变量或者一个结构体,若是结构体,它的属性可以为指针类型,那么此后,每个引用的副本中都有该指针类型的属性,而且它们都是指向同一个内存块

指针一个存储内存块的地址 的变量。
区别

  • 数组是值类型,切片是引用类型;
  • 数组的长度是固定的,而切片不是(切片是动态的数组)
  • 切片的底层是数组

3. 能介绍下 rune 类型吗?

前言golang中string底层是通过byte数组实现的(所以获取字符串长度是按照字节来的)。中文字符在unicode下占2个字节,在utf-8编码下占3个字节。(golang默认编码是utf-8)。

介绍:相当于 int32。rune 是用来处理unicode或utf-8字符的(byte用来处理ascii字符)。举例(使用
[ ] rune来接受字符串时,它能够正确获取字符串长度。)

4. 调用函数传入结构体时,应该传值还是指针?

传值会拷贝整个对象,而传指针只会拷贝指针地址,指向的对象是同一个。传指针可以减少值的拷贝,但是会导致内存分配逃逸到堆中增加垃圾回收(GC)的负担

一般情况下,对于需要修改原对象值,或占用内存比较大的结构体,选择传指针。对于只读的占用内存较小的结构体,直接传值能够获得更好的性能。

5. 调用函数传参(slice、map、chan)时候,传的是什么?

Go语言中所有的传参都是值传递(传值),都是一个副本,一个拷贝。因为拷贝的内容有时候是非引用类型(int、string、struct等这些),这样就在函数中就无法修改原内容数据;有的是引用类型(指针、map、slice、chan等这些),这样就可以修改原内容数据。
golang中所有函数参数传递都是传值,slice、map和chan看上去像引用只是因为他们内部有指针或本身就是指针而已。(slice其实是一个含有指针的结构体,而map和slice本身就是一个指针)

6. 讲讲 Go 的 select 底层数据结构和一些特性?

概念:go 的 select语句会依次检查每个case分支,如果其中有一个通道已经准备好,就会执行相应的操作。如果有多个通道都已经准备好,select语句会随机选择一个通道执行相应的操作。
底层数据结构:select语句的底层数据结构是一个select结构体,它包含了多个case分支和一个默认分支。
特性
(1)case语句必须是一个channel操作。
(2)select中的default子句总是可运行的
(3)select语句可以阻塞等待通道操作。
(4)如果有多个case都可以运行,select会随机公平地选出一个执行。
(5)所有channel表达式都会被求值

7. 讲讲 Go 的 defer 底层数据结构和一些特性?

每个defer语句都会创建一个defer结构体,并将其添加到当前函数的defer链表中。当函数返回时,Go运行时会依次执行defer链表中的函数直到链表为空为止。这个过程是在函数返回之前执行的,因此可以保证被延迟执行的函数在函数返回之前被执行。
defer 的规则总结
(1)延迟函数执行按照后进先出的顺序执行,即先出现的 defer 最后执行。
(2)延迟函数可能操作主函数的返回值。(先执行return,后执行defer。defer是用来完成收尾工作的)
(3)申请资源后立即使用 defer 关闭资源是个好习惯

8. map 的数据结构是什么?是怎么实现扩容?

数据结构:map的底层数据结构是hmap,hmap有多个bmap桶,每个bmap桶包含一个哈希链表,哈希链表中的每个元素都包含一个键值对。
解决哈希冲突:当向map中插入一个元素时,Go运行时会先计算元素的哈希值,然后根据哈希值找到对应的桶。如果桶中已经存在一个元素,那么新元素会被插入到链表的头部;
怎么扩容:Go 会创建一个新的 buckets 数组新的 buckets 数组的容量是旧buckets数组的两倍(或者和旧桶容量相同),将原始桶数组中的所有元素重新散列到新的桶数组中。这样做的目的是为了使每个桶中的元素数量尽可能平均分布,以提高查询效率。旧的buckets数组不会被直接删除,而是会把原来对旧数组的引用去掉,让GC来清除内存。在map进行扩容迁移的期间不会触发第二次扩容。只有在前一个扩容迁移工作完成后,map才能进行下一次扩容操作。(注意:以上的搬迁过程为渐进式搬迁的策略
扩容时机
(1)当装载因子超过6.5时,扩容一倍,属于增量扩容;
(2)当使用的溢出桶(bmap中有溢出桶这个属性)过多时,重新分配一样大的内存空间,属于等量扩容;

9. slices能作为map类型的key吗?

在golang规范中,可比较的类型都可以作为map key
不能作为map key 的类型包括
(1)slices
(2)maps
(3)functions

10. map 使用注意的点,是否并发安全?

  1. 注意的点
    (1)map的键必须是可比较的类型,否则会在编译时报错。
    (2)map是无序的,不能保证遍历的顺序和插入的顺序一致。
    (3)map的值可以为任意类型,但键必须是可比较的类型。
    (4)在并发环境下,map不是并发安全的,需要使用互斥锁等机制进行保护。
  2. 解决并发安全
    (1)sync.Map —— 可以安全地在多个goroutine之间并发访问(在使用sync.Map时,需要注意它的一些限制,例如不能使用range遍历、不能在Load和Store方法中传递指向map的指针等)。

11. map 中删除一个 key,它的内存会释放么?

golang的map在key被删除之后,并不会立即释放内存。将map设置为nil后,内存被释放。

12. 解决对 map 进行并发访问?

  1. sync.Map —— 可以安全地在多个goroutine之间并发访问
  2. 对map进行并发访问时,需要使用锁来保证并发安全。常用的锁包括互斥锁(sync.Mutex)和读写锁(sync.RWMutex)

13. nil map 和空 map 有何不同?

nil map 未初始化,空map是长度为空

14. context 结构是什么样的?context 使用场景和用途?

浅入:context通知一个或多个goroutine停止。( context.WithCancel 函数)

func TestContext(t *testing.T) {
	ctx, cancel := context.WithCancel(context.Background())
	go worker(ctx, "zhang")
	go worker(ctx, "li")

	time.Sleep(5 * time.Second)

	cancel()

	time.Sleep(1 * time.Second)

	fmt.Println("all the workers take a rest")

}

func worker(ctx context.Context, name string) {
	for {
		select {
		case <-ctx.Done():
			fmt.Println("worker stopped")
			return
		default:
			fmt.Println("working...")
			time.Sleep(time.Second)
		}
	}
}

其余细节待更

15. channel 的底层实现 (数据结构)

type hchan struct {
	qcount   uint           // 循环队列元素的数量
	dataqsiz uint           // 循环队列的大小
	buf      unsafe.Pointer // 循环队列缓冲区的数据指针
	elemsize uint16         // chan中元素的大小
	closed   uint32         // 是否已close
	elemtype *_type         // chan 中元素类型
	sendx    uint           // send 发送操作在 buf 中的位置
	recvx    uint           // recv 接收操作在 buf 中的位置
	recvq    waitq          // receiver的等待队列
	sendq    waitq          // senderl的等待队列

	lock mutex 				// 互斥锁,保护所有字段
}
  1. 回复:底层是,qcount 当前队列中剩余元素个数,dataqsiz 环形队列长度,即可以存放的元素个数,buf 环形队列指针,elemsize 每个元素的大小,closed 标识关闭状态,elemtype 元素类型,sendx 队列下标,指示元素写入时存放到队列中的位置,recvx 队列下标,指示元素从队列的该位置读出。recvq 等待读消息的 goroutine 队列,sendq 等待写消息的 goroutine 队列,lock 互斥锁,chan 不允许并发读写。
  2. 注意:循环队列好处为一个数据被发送之后,其余数据不用移动。

16. 向 channel 写入数据和从 channel 读出数据的流程是什么样的?

  1. 向channel写入数据
    若 读出等待队列不为空,则把数据发送给 读出等待队列的第一个goroutine,并唤醒。
    若 读出等待队列为空,若有缓冲区,则将数据写入缓冲区。若无缓冲区,将走阻塞写入的流程,将当前goroutine加入写入等待队列。并挂起等待唤醒。
  2. 从channel读出数据
    若 写入等待队列不为空,则从写入等待队列的第一个goroutine中读出数据,并唤醒。
    若 写入等待队列为空,若有缓冲区,则从缓冲区读出数据。若无缓冲区,将走阻塞读出的流程,将当前goroutine加入读出等待队列。并挂起等待唤醒。

17. channel特点

  1. 关闭的管道读数据仍然可以读数据,但不可以写入数据(会panic)。(注:从一个已经关闭且没有剩余数据的管道中读取数据,则会返回该类型的零值)
  2. 向nil 管道读写会永久阻塞
  3. 关闭为 nil 的管道 panic
  4. 关闭已经关闭的管道 panic

18. channel有无缓冲区的区别

  1. 管道没有缓冲区,从管道读数据会阻塞,直到有协程向管道中写入数据。同样,向管道写入数据也会阻塞,直到有协程从管道读取数据。
  2. 管道有缓冲区但缓冲区没有数据,从管道读取数据也会阻塞,直到协程写入数据,如果管道满了,写数据也会阻塞,直到协程从缓冲区读取数据。

19. channel 是否线程安全?锁用在什么地方?

  1. 为什么设计成线程安全? 不同协程通过channel通信,所以channel的使用场景是多线程下,故需保证数据的一致性
  2. 如何实现线程安全? channel的锁是通过hchan结构体中的mutex字段实现的,它是一个互斥锁用于保护channel的读写操作。(当一个goroutine进行读写操作时,它会先获取该锁,然后进行相应的操作,最后释放锁。在进行读写操作时)
  3. 锁用在什么地方? 锁的作用是保护channel的状态(容量、是否已关闭等信息)和 缓冲区

20. 有那些方式可以安全的读写共享变量?

  1. 将共享变量的读写放到一个 goroutine 中,其它 goroutine 通过 channel 进行读写操作。
  2. 可以用个数为 1 的信号量(semaphore)实现互斥
  3. 将共享变量的读写放到一个 channel 中
  4. 加锁(例如通过sync包下的Mutex)
  5. 通过原子操作(例如通过sync包下的atomic)

22. Mutex 是悲观锁还是乐观锁?悲观锁、乐观锁是什么?

Mutex是悲观锁

  1. 乐观锁:乐观锁认为对同一数据的并发操作不会总发生,属于小概率事件,不用每次都对数据上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据。不采用数据库自身的锁机制,而是通过程序来实现。
  2. 悲观锁:对数据被其他事务的修改持保守态度,会通过数据库自身的锁机制来实现,从而保证数据操作的排它性。每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会 阻塞 直到它拿到锁

23. Mutex 有几种模式?

  1. 正常模式
    正常模式下,所有等待锁的 goroutine 按照 FIFO(先进先出)顺序等待。唤醒 的 goroutine 不会直接拥有锁,而是会和新请求 goroutine 竞争锁(还会和处于自旋状态的goroutine竞争 - 这里面的自旋状态的无非就是某次新请求没有竞争成功,进入了自旋状态)。新请求的 goroutine 更容易抢占(因为它正在 CPU 上执行),所以刚刚唤醒的 goroutine有很大可能在锁竞争中失败。在这种情况下,这个被唤醒的 goroutine 会加入到等待队列的前面。不过加入之前会判断自上次阻塞到本次阻塞经过了多长时间,如果超过 1ms 的话,会将 Mutex 标记为饥饿模式
  2. 饥饿模式
    饥饿模式下,直接由 unlock 把锁交给等待队列中排在第一位的 goroutine (队头),同时,饥饿模式下,新进来的 goroutine 不会参与抢锁也不会进入自旋状态,会直接进入等待队列的尾部。这样很好的解决了老的 goroutine一直抢不到锁的场景。
    饥饿模式的触发条件:当一个 goroutine 等待锁时间超过 1 毫秒时,或者当前 队列只剩下一个 goroutine 的时候,Mutex 切换到饥饿模式。

自旋状态:一个goroutine认为一个事件即将发生,它不会进入睡眠状态,自己一直在做空循环,一直等待事件的发生。

25. 怎么控制并发数

  1. 使用goroutine和channel。可以使用goroutine和channel来控制并发数,例如创建一个缓冲大小为N的通道,表示最多只能有N个goroutine同时执行。在每个goroutine开始执行时,将一个空结构体(或其他无意义的数据)发送到通道中,如果通道已满,则当前goroutine会被阻塞,直到有一个goroutine执行完成并从通道中接收了一个数据。在每个goroutine执行完成后,从通道中接收一个数据,以便其他goroutine可以执行。
func main() {
	count := 10 // 最大支持并发
	sum := 100 // 任务总数
	wg := sync.WaitGroup{} // 控制主协程等待所有子协程执行完之后再退出 (在该程序中是为了让所有的任务(100个)都执行完)

	c := make(chan struct{}, count) // 控制任务并发的chan
	defer close(c)

	for i:=0; i<sum;i++{
		wg.Add(1)
		c <- struct{}{} // 在通道中满时候,会阻塞在此
		go func(j int) {
			defer wg.Done()
			fmt.Println(j)
			<- c // 执行完毕,该goroutine对应的空结构体也被流出
		}(i)
	}
	wg.Wait()
}
  1. 使用ants
func main() {
	pool, err := ants.NewPool(3) // 创建一个大小为 3 的协程池
	if err != nil {
		panic(err)
	}
	defer pool.Release() 
	wg := sync.WaitGroup{} // 定义一个等待组,用于等待所有任务完成
	for i := 0; i < 10; i++ {
		wg.Add(1)
		err := pool.Submit(func() { //  使用 submit 方法来提交任务到协程池中
			fmt.Printf("Task %d is done.\n", i)
			wg.Done()
		})
		if err != nil {
			panic(err)
		}
	}
	wg.Wait()
}

26. 用defer捕获异常


func main() {
	defer func() {
		if err := recover(); err != nil {
			fmt.Println("捕获到异常:", err)
		}
	}()

	// 产生异常
	panic("出现了一个异常")
}

recover() 函数的返回值是 panic() 函数传递的参数。

27. 设计一个优雅的线程池

type GoroutinePool struct {
	minGoroutines int            // 最小协程数量
	maxGoroutines int            // 最大协程数量
	queue         chan func()    // 任务队列
	wg            sync.WaitGroup // 等待组(等待任务队列中被各协程完成)
}

func NewGoroutinePool(minGoroutines, maxGoroutines int) *GoroutinePool {
	return &GoroutinePool{
		minGoroutines: minGoroutines,
		maxGoroutines: maxGoroutines,
		queue:         make(chan func(), 100),
	}
}

func (p *GoroutinePool) Start() {
	for i := 0; i < p.minGoroutines; i++ {
		go p.worker()
	}
	go p.adjust()
}

func (p *GoroutinePool) worker() {
	for task := range p.queue {
		task()
		p.wg.Done()
	}
}

func (p *GoroutinePool) Submit(task func()) {
	p.wg.Add(1)
	p.queue <- task
}

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

func (p *GoroutinePool) adjust() {
	for {
		time.Sleep(time.Second)
		select {
		case <-p.queue:
			if p.minGoroutines < p.maxGoroutines && len(p.queue) > p.minGoroutines {
				p.minGoroutines++
			}
		default:
			if len(p.queue) < p.minGoroutines && p.minGoroutines > 1 {
				p.minGoroutines--
			}
		}
		go p.worker()
	}
}

func main() {
	pool := NewGoroutinePool(2, 5)
	pool.Start()

	for i := 0; i < 6; i++ {
		pool.Submit(func() {
			fmt.Println(1)
		})
	}

	pool.Wait()
}

28. Channel 分配在栈上还是堆上?哪些对象分配在堆上,哪些对象分配在栈上?

在 Golang 中,Channel 是分配在堆上的,因为 Channel 的作用域和生命周期不仅仅限于某个函数内部,而且需要在协程间传递数据,因此需要在堆上分配空间。

至于哪些对象分配在堆上,哪些对象分配在栈上,这取决于编译器和逃逸分析的结果。一般来说,函数的局部变量会被分配在栈上,而全局变量和动态分配的对象(如使用 new 或 make 函数创建的对象)会被分配在堆上。但是,如果编译器可以确定一个局部变量在函数返回后不再被引用,那么它就可以将该变量分配在栈上,而不是堆上,以提高程序的效率。逃逸分析是一种静态分析技术,可以帮助编译器确定变量的作用域和生命周期,从而优化变量的分配方式。

29. a := 1,a在堆上,还是栈上?

在 Golang 中,基本类型的变量(如 int、float、bool 等)和小的结构体变量通常会被分配在栈上。因此,当你声明一个变量 a := 1 时,a 变量会被分配在栈上。
注意:如果变量 a 被传递给一个函数,或者被存储到一个堆数据结构中,那么它可能会被分配到堆上。这是因为编译器会进行逃逸分析,如果发现变量 a 的生命周期超出了当前函数的作用域,那么它就会被分配到堆上,以确保它在函数返回后仍然可以被访问。

30. 请简述 Go 是如何分配内存的?

  1. Go的内存分配原则

Go在程序启动的时候,会先向操作系统申请一块内存,分为栈内存和堆内存:

栈内存管理和分配原则:

管理:在 Go 语言中,栈内存的管理是由编译器和运行时系统自动完成的。每个 Goroutine(协程)都会拥有一块独立的栈空间,当该 Goroutine 结束时,其栈空间所占用的内存就会被自动释放。

分配原则:
(1)小于32KB的栈内存。依次来源:mcache 线程缓存 -> stackpool 全局缓存 -> p.pagecache 逻辑处理器结构 -> mheap 堆
(2)大于等于32KB的栈内存。依次来源:stackpool 全局缓存 -> p.pagecache 逻辑处理器结构 -> mheap 堆

堆内存管理和分配原则:

管理: 程序申请到的堆内存块被分配了三个区域:
在这里插入图片描述
spans: spans区域存放mspan(是一些arena分割的页组合起来的内存管理基本单元)的指针
bitmap:bitmap区域标识arena区域哪些地址保存了对象
arena: arena区域就是我们所谓的堆区,Go动态分配的内存都是在这个区域,它把内存分割成8KB大小的页,一些页组合起来称为mspan。

分配原则:

(1)小于等于16B内存分配:它是在每个M的mcache上的微型分配器进行分配。
(2)小于等于32K大于16B内存分配:GPM调度模型中,每个M绑定一个p,每个p绑定一个mcache ,当该pP管理的本地队列中的某个g想要申请内存时候,会从mcache中申请mspan。如果没有空闲的mspan或者没有特定大小的mspan了,则mcache就会向mcentral中获取。mcentral被所有线程共享。当 mcentral 没有空闲的 mspan 时,会向p的pagecache中获取,如果pagecache没有空闲的mspan,会向 mheap 申请。而 mheap 没有资源时,会向操作系统申请新内存。
(3)大于32kb内存分配:对于那些超过32KB的内存申请,会向p的pagecache中获取,如果pagecache没有空闲的mspan,会从堆上分配对应的数量的内存页(每页大小是8KB)。

31. 知道 golang 的内存逃逸吗?什么情况下会发生内存逃逸?

  1. 内存逃逸是什么:本该分配到栈上的变量,跑到了堆上,这就导致了内存逃逸
  2. 哪几种情况下有内存逃逸
    (1)方法内返回局部变量指针,则这些变量会逃逸到堆中
    (2)向 channel 发送指针数据,则这些数据会逃逸到堆中
    (3)在闭包中引用包外的值,则这些数据会逃逸到堆中
    (5)slices 中存储指针或是带有指针的值, 则这些数据会逃逸到堆中
    (6)interfacec{}类型
    (7)栈内存溢出
  3. 怎么避免内存逃逸
    (1)尽量避免调用接口类型的方法
    (2)避免使用函数中指针类型的局部变量
  4. 逃逸分析
    (1)查看:通过go build -gcflags '-m’命令来观察变量逃逸情况
    (2)注意:Go编译器会在编译期对考察变量的作用域,就可能会出现内存逃逸。
  5. 内存逃逸危害
    (1)变量在堆上的分配和回收都比在栈上开销大的多。对于 go 这种带 GC 的语言来说,会增加 gc 压力,同时也容易造成内存碎片。

32. 谈谈内存泄露,什么情况下内存会泄露?怎么定位排查内存泄漏问题?

  1. 内存泄漏是什么:在程序运行过程中,分配的内存空间没有被正确释放或回收的情况。
  2. 哪几种情况下有内存泄漏
    (1) goroutine 在执行时被阻塞而无法退出
    (2)互斥锁未释放或者造成死锁会造成内存泄漏
    (3)使用了time.Ticker但是没有调用stop()方法
    (4)字符串的截取引发的内存泄漏
func main() {
	var str0 = "12345678901234567890"
	str1 := str0[:10]
} 

str0和str1共享内存,str1活跃,则str0也就要一直存在,如果str0足够大,str1截取足够小,或者在高并发场景中频繁使用,那么可想而知,会造成临时性内存泄漏,对性能产生极大影响。
解决方法:1. strings.Repeat(str0[ :10], 1) 2. 转byte然后转string
(5)切片截取引起子切片内存泄漏


func main() {
	var s0 = []int{1, 2, 3, 4, 5, 6, 7, 8, 9}
	s1 := s0[:5]
}

这种情况与字符串截取引起的内存泄漏情况类似,s1活跃情况下,造成s0中部分内存泄漏。
解决方法: s1 := append(s0[:0:0], s0[:5]…) // 在一个容量为0的空切片中进行append,也就生成了一个新的切片。
(6)函数数组传参引发内存泄漏(参数内存很大)
数组是值传递,所以当高并发情况下向该函数进行数组传参,并且数组长度很大,那么就会消耗巨大内存
3. 排查方式
(1)一般通过 pprof 是 Go 的性能分析工具

33. GPM调度模型

下面这段话看不懂的话,先学习一下GPM调度模型。视频链接 GPM调度模型这个视频不是我的,我觉得他讲的可以

  1. 什么是GPM
    G 代表着 goroutine,P 代表着 协程处理器(用于向M提供G队列),M 代表 thread 线程(G的真正执行者),在 GPM 模型,有一个全局队列(Global Queue):存放等待运行的 G,还有一个 P 的本地队列:也是存放等待运行的 G,但数量有限,不超过 256 个。GPM 的调度流程从 go func()开始创建一个 goroutine,新建的 goroutine 优先保存在 P 的本地队列中,如果 P 的本地队列已经满了,则会保存到全局队列中。M 会从 P 的队列中取一个可执行状态的 G 来执行,如果 P 的本地队列为空,会优先从全局队列中取 G,然后再尝试从其它 P 的本地队列中偷取 G。若某个时刻,M 执行某一个 G 时候,G发生系统调用或者阻塞,该阻塞的G会与M进行绑定,然后P会找新的M来继续执行下一个G,首先会从休眠线程队列中找M,并与刚找出的M进行绑定,继续执行P的本地队列中的G。若休眠线程中没有M,则该P会被加入空闲P队列。等到原来的M执行的G为非阻塞状态,因为它的P已经走了,所以它没有办法继续执行G,此时该M会优先会获取原配,然后从空闲P队列中取,否则该G放入全局队列,该M休眠线程队列中。

  2. 注意
    (1)当某个 P 的本地队列中没有 G 时,会优先从全局队列中取 G,然后再尝试从其它 P 的本地队列中偷取 G。(从源码可看到,先从本地队列查询,全局队列查询,然后再从其他P里偷取,具体源码在runtime的proc.go里)
    (2)GOMAXPROCS是所有P个数,故有 自旋线程 + 执行线程 <= GOMAPPROCS
    (3)自旋线程的概念:全局中没有G了,目前执行的是G0,需要从其它P的本地队列中偷取。
    (4)M0、G0的概念:m0是一个进程的第一个线程,负责执行和初始化第一个g(通常是main);g0是在协程切换完成上下文切换。

34. go调度机制中的抢占

因为整个Go程序都是运行在用户态的,所以不能像操作系统那样利用时钟中断来打断运行中的goroutine。也得益于完全在用户态实现,goroutine的调度切换更加轻量。goroutine的调度器也用到了时间片算法,但是和操作系统的线程调度还是有些区别的,主要分为基于协作的抢占式调度基于信号量的抢占式调度

  • 协作式调度的核心在于G在M上运行的时候主动让出M
  • 基于信号量的抢占式调度的本质是G没有主动让出M的时候强行中断M对G的执行

以下是理解:

  1. 基于协作的抢占式调度
    (1)通过设置环境变量 GODEBUG=asyncpreempt=off启用
    (2)对于Go语言中运行时间过长的goroutine,会有一个后台线程持续监控,一旦运行超过10ms,会设置goroutine的协作标识位,资源会被抢占走。
  2. 基于信号量的抢占式调度
    (1)preemptone和preemptM都是用于实现M抢占机制的。preemptone是一个定时器,用于定期检查所有的M是否需要被抢占。如果某个M的运行时间超过了一定的阈值,preemptone会向该M发送抢占信号,让该M主动放弃CPU,以便其他M有机会运行。preemptM是一个函数,它会在需要进行抢占的时候被调用。它会向需要进行抢占的M发送SIGURG信号,以触发该M的抢占操作。

35. 进程、线程、协程有什么区别?

  1. 进程:是操作系统中资源分配的基本单位,每个进程都有独立的内存空间、代码和数据。进程之间的切换开销较大,因此进程间的并发性较低。
  2. 线程:是进程中的执行单元,一个进程可以包含多个线程,它们共享进程的资源。线程之间的切换开销较小,因此线程间的并发性较高。
  3. 协程:是一种用户态的轻量级线程,它不需要操作系统的支持,可以在用户程序中实现。协程通常运行在单个线程中,因此协程的并发性比线程更高。

36. GC

下面这些话看不懂的话,先看GC的讲解视频 GC原理视频,(这个视频不是我的,我觉得他讲的可以

  1. GC机制随着golang版本变化如何变化的
    Go V1.3 之前普通标记清除(mark and sweep)方法,整体过程需要启动 STW,效率极低。
    GoV1.5 三色标记法,堆空间启动写屏障栈空间不启动。全部扫描之后,需要重新扫描一次栈(需要 STW),效率普通。
    GoV1.8 三色标记法,混合写屏障机制:栈空间不启动(全部标记成黑色),堆空间启用混合写屏障,整个过程不要 STW,效率高。
  2. 三色标记法
    (1)基本思路:首先将所有对象都放入白色标记表中,然后遍历程序的根节点(只遍历一层),得到灰色节点,然后遍历该灰色节点,将可达的对象,从白色标记为灰色,自身变为黑色。重复上面步骤,知道灰色标记表中无任何对象。
    (2)最不希望发生的事(会造成对象无辜的被清理):一个白色对象被黑色对象引用 灰色对象与它之间的可达关系的白色对象遭到破坏。
       解决方法强弱三色不变式 —— 破坏条件1,即强制性的不允许黑色对象引用白色对象; —— 破坏条件2,黑色对象引用白色对象时,需要满足白色对象存在其它灰色对象对它的引用,或者可以达它的链路上游存在灰色对象),这种强弱不变式就是 屏障机制
  3. 屏障机制的实现(插入屏障 和 删除屏障)
    (1)插入屏障(强三色不变式):(对象被引用时触发的机制)在A对象引用B对象时候,B对象被标记为灰色。(插入屏障不在栈上使用,因为性能影响大,所以会导致栈上黑色对象创建对象时会有无辜对象被gc清理,所以最终需要进行stw,然后重新扫描一次栈) 缺点:结束时需要STW来重新扫描栈(防止对象丢失,因为黑色对象会创建对象,所以最终会重新扫描一次栈,需要进行短暂的STW)。
    (2)删除屏障(弱三色不变式):(对象被删除时触发的机制)被删除的对象,如果自身为灰色或者白色,那么被标记为灰色。保护了被删除对象,因为它有可能此时被其它对象引用。缺点:回收精度低。一个对象被删除,此轮会存活。下一轮才会被GC清理掉。
    (3)混合写屏障机制GoV1.8的三色标记法),步骤如下:
        1. GC开始将栈上的对象全部扫描并标记为黑色
        2. GC期间,任何在栈上创建的新对象,均为黑色
        3. 被删除的对象标记为灰色
        4. 被添加的对象标记为灰色

请添加图片描述

  • 7
    点赞
  • 25
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
Golang 提供了一些底层数据结构,这些数据结构可以用于构建高效的程序。以下是一些常见的底层数据结构: 1. 数组(Arrays):在 Golang 中,数组是固定长度的数据结构,可以存储相同类型的元素。数组使用索引访问元素,具有快速的随机访问能力。 2. 切片(Slices):切片是一个动态长度的数组,可以根据需要进行扩展或收缩。切片是基于数组实现的,提供了更灵活的操作和更方便的使用。 3. 映射(Maps):映射是一种无序的键值对集合。它类似于字典或哈希表,通过键来访问值。Golang 的映射使用哈希表来实现,具有快速的查找和插入能力。 4. 链表(Linked Lists):链表是一种基本的数据结构,它由多个节点组成,每个节点包含一个值和一个指向下一个节点的指针。链表可以用于实现队列、栈和其他高级数据结构。 5. 栈(Stacks):栈是一种后进先出(LIFO)的数据结构,只能在栈顶进行插入和删除操作。Golang 中可以使用切片或链表实现栈。 6. 队列(Queues):队列是一种先进先出(FIFO)的数据结构,只能在队尾进行插入操作,在队头进行删除操作。Golang 中可以使用切片或链表实现队列。 7. 堆(Heaps):堆是一种特殊的二叉树,具有一些特定的性质。在 Golang 中,可以使用堆接口和堆包来实现最小堆或最大堆。 8. 树(Trees):树是一种非线性数据结构,由节点和边组成。树在计算机科学中有广泛的应用,如二叉树、AVL 树、红黑树等。 这些底层数据结构可以帮助开发者构建高效的程序,并在不同的应用场景中发挥作用。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值