6.24面试日记

6.24面试日记

由于明后两天有连着三场面试,并且这次面试过后就是期末考试和暑假了,还是很希望在暑假找个实习然后不回家的,所以得好好准备一把了。

简历上的部分

image-20250624162900975image-20250624162909752image-20250624162917623image-20250624162923373

这是我简历上的内容,我的简历缺点是缺少中间件了,但是我怕写了答不出来就没写了,我还是太菜了,八股文背得少,诶诶诶。

想到的可能在简历上问的八股

GMP调度模型

G:goroutine是一种比线程更轻量级的并发编程方式,它允许在单个线程内执行多个任务,并且可以在任务之间进行切换,而不需要线程上下文切换的开销。协程通过协作式多任务处理来实现并发,这意味着任务之间的切换是由程序显示控制的,而不是由操作系统调度的。

以下是协程的一些关键特征:

  • 轻量级:协程的创建和切换开销非常小,因为它们不需要操作系统级别的线程管理。
  • 非抢占式:协程的切换是显式的,由程序员在代码中指定,而不是由操作系统抢占式地调度。
  • 状态保存:协程可以在暂停执行时保存其状态,并在恢复执行时从暂停的地方开始。
  • 异步编程:协程非常适合用于异步编程,特别是在I/O密集型任务中,可以在等待I/O操作完成时切换到其他任务,从而提高程序的并发性和效率。

Goroutine实现原理

Goroutine的实现原理包括Goroutine的创建、调度、上下文切换和栈管理等多个方面。通过GMP模型和高效的调度机制,GO运行时能够高效地管理和调度大量的Goroutine,实现高并发编程。

Goroutine的创建

当使用go关键字启动一个新的Goroutine时,go运行时会执行以下步骤:

  • 分配G结构体:Go运行时会为新的Goroutine分配一个G结构体,其中包含Goroutine的状态信息,栈指针,程序计数器等。
  • 分配栈空间:Go运行时会为新的Goroutine分配初始的栈空间,通常是几kb。
  • 初始化G结构体,将Goroutine的入口函数、参数、栈指针等信息填入G结构体中。
  • 将Goroutine加入调度队列:Go运行时会将新的Goroutine加入到某个Processor的本地运行队列中,等待调度执行。

Goroutine的调度

go运行时使用GMP模型来管理和调度Goroutine

  • P(Processor):P是Go运行时的一个抽象概念,表示一个逻辑处理器。每个P持有一个本地运行队列,用于存储执行的Goroutine。P的数量通常等于机器的CPU核心数,可以通过runtime.GOMAXPROCS函数设置。
  • M(Machine):M表示一个操作系统线程。M负责实际执行P中的Goroutine。M和P是一对一绑定的关系,一个M只能绑定一个P,但应该P可以被多个M绑定(通过抢占机制)。M的数量是由Go运行时系统动态管理和确定的。M的数量并不是固定的,而是根据程序的运行情况和系统资源的使用情况动态调整的。通过runtime.NumGoroutine()和runtime.NumCPU()函数,我们可以查看当前的Goroutine数量和CPU核心数。Go运行时对M的数量有一个默认的最大限制,以防止创建过多的M导致系统资源耗尽。这个限制可以通过环境变量GOMAXPROCS进行调整,但通常不需要手动设置。
  • G(Goroutine):代表一个Goroutine,它有自己的栈,instruction pointer和其他信息(正在等待的channel等等),用于调度。
  • 调度循环:每个P会在一个循环中不断从本地运行队列中取出Goroutine,并将其分配给绑定的M执行。如果P的本地运行队列为空,P会尝试从其他的P的本地运行队列中窃取Goroutine(工作窃取机制)。

P的数量可以大于器的CPU核心数?

在Go语言中,P(Processor)的数量通常等于机器的CPU核心数,但也可以通过runtime.GOMAXPROCS函数进行调整。默认情况下,Go运行时会将P的数量设置为机器的逻辑CPU核心数。然而,P的数量可以被设置为大于或小于机器的CPU核心数,这取决于具体的应用需求和性能考虑。

P的数量与CPU核心数的影响
  • P的数量大于CPU核心数的影响
    • 上下文切换增加:当P的数量大于CPU核心数时,可能会导致更多的上下文切换。因为操作系统需要在有限的CPU核心上调度更多的线程M。这可能会增加调度开销。
    • 资源竞争:更多的P意味着更多的Goroutine可以同时运行,但这也导致了更多的资源竞争,特别是在I/O密集型任务中。过多的P可能会导致资源争用,反而降低程序的整体性能,
    • 并发性提高:在某些情况下,增加P的数量可以提高程序的并发性,特别是在存在大量阻塞操作(如I/O操作)的情况下。更多的P可以更好地利用CPU资源,减少阻塞时间。
  • P的数量少于CPU核心数量的影响
    • CPU利用率降低:当P的数量小于CPU核心数时,可能会导致CPU资源未被充分利用。因为P的数量限制了同时运行的Goroutine数量,可能会导致某些CPU核心处于空闲状态。
    • 减少上下文切换:较少的P数量可以减少上下文切换的开销,因为操作系统需要调度的线程M数量减少。这可能会提高CPU密集型任务的性能。

选择合适的P数量选择合适的P数量需要根据具体的应用场景和性能需求进行调整,以下是一些建议:

  • CPU密集型任务:对于CPU密集型任务,通常将P的数量设置为等于或接近机器的逻辑CPU核心数,以充分利用CPU资源。
  • I/O密集型任务:对于I/O密集型任务,可以考虑将P的数量设置为大于CPU核心数,以提高并发性和资源利用率。
  • 性能测试和调优:通过性能测试和调优,找到最佳的P数量设置。可以尝试不同的P数量,观察出现的性能表现,选择最优的配置。
Goroutine的栈管理

Goroutine的栈空间是动态分配的,可以根据需要自动扩展。Go运行时使用分段栈活连续栈来管理Goroutine的栈空间:

  • 分段栈:在早期版本的Go中,GOroutine使用分段栈。每个GOroutine的栈由多个小段组成,当栈空间不足时,Go运行时会分配新的栈段并链接到现有的栈段上。
  • 连续栈:在GO1.3及以后的版本中,GOroutine使用连续栈。每个GOroutine的栈是一个连续的内存块,当栈空间不足时,Go运行时会分配一个更大的栈,并将现有的栈内容复制到新的栈中。

GC机制

Golang的垃圾回收机制采用并发三色标记清除法+混合写屏障来实现

并发三色标记清除法

具体来说,根据对象的应用关系:

  • 有黑白灰三个集合,初始所有对象都是白色
  • 从root对象开始标记,将所有可达对象标记为灰色
  • 从灰色对象集合取出对象,将其应用的对象标记为灰色,放入灰色集合,并将自己标记为黑色
  • 重复上一步,知道灰色集合为空,即所有可达对象都被标记了灰或黑
  • 将白色对象进行清除回收
  • 重置GC状态

并发GC导致的问题

因为整个流程与代码并发执行,所以会出现一些edge case导致对象被漏标,多标的情况

漏标问题指的是在用户协程与GC协程并发执行的场景下,部分存货对象未被标记从而误删

多标指的是在用户协程与GC协程并发执行的场景下,部分垃圾对象被误标记导致GC未按时将其回收

屏障机制

屏障机制类似于一个回调保护机制,指的是在完成某个特定动作前,会先完成屏障设置的内容

其次一套用于解决漏标多标问题的方法论被提出来,称之为强弱三色不变式:

  • 强三色不变式:三色标记需要维护:白色对象不能被黑色对象直接引用
  • 弱三色不变式:三色标记需要维护:白色对象可以被黑色对象引用,但要从某个灰对象出发仍然可达该白色对象

插入写屏障的目标是实现强三色不变式,保证当一个黑色对象指向一个白色对象前,会先触发屏障将白色对象设置为灰色,再简历引用

删除写屏障的目标是实现弱三色不变式,保证当一个白色对象即将被上游删除引用前,会触发屏障将其置灰,之后再删除上游指向其的引用

简单理解就是,要指向一个白色对象时,先触发屏障把颜色改为灰,保证GC扫描不出问题,再走后面的程序逻辑;同理要删一个白色对象时,先触发屏障把颜色改成灰色,保证GC扫描不出问题,再走后面的程序逻辑

了解过go的内存模型吗?大致讲一讲

go内存模型指定了一系列条件,在这些条件下,可以保证在一个Goroutine钟读取变量可以观察到其他Goroutine钟对同一变量所写的值。即安全在不同的协程中读写变量。

为什么需要这些条件?

编译器或处理器不保证指令执行顺序和程序书写顺序一致,同时读写变量由于变量体积不同,也不一定是原子性的。

channel是什么,有什么作用?

  • channel是Goroutine之间的通信的管道,遵循"不要通过共享内存来通信,而是通过通信来共享内存"
  • 类型化:每个channel只能传递特定类型的数据
  • 主要用途:数据传递、Goroutine同步、信号通知
  • 分类:无缓冲channel(同步,发送和接收必须同时准备好)、有缓冲channel(异步,有一定容量)。
sync.WaitGroup的用途和使用场景?

解读:考察并发同步原语的使用。

思路:

  • sync.WaitGroup用于等待一组Goroutine完成执行。
  • 主要方法:Add(delta int)增加计数器,Done()减少计数器(通常在Goroutine结束时用defer调用),wait()阻塞直到计数器归零。
  • 场景:主Goroutine需要等待多个子Goroutine完成某些任务后再继续执行。
select语句的作用是什么?
  • 解读:考察多路复用channel操作的能力
  • 思路:
    • select语句勇者多个channel操作中进行选择。
    • 行为类似switch,但其case是channel的发送或接收操作。
    • 如果多个case同时就绪,select会随机选择一个执行。
    • 可以有default子句,当所有case都不就绪执行default(实现非阻塞操作)。
如何实现一个Goroutine安全的计数器?
  • 解读:考察并发访问共享资源时的同步问题。
  • 思路:
    • 直接并发修改共享变量会导致竞态条件。
    • 方法1:使用sync.Mutex(互斥锁)保护临界区
    • 方法2:;使用sync/atomic包提供的原子操作。
    • 方式3:通过channel将所有修改串行到单个Goroutine处理。
context包的作用
  • 解读:考察对Goroutine生命周期管理、取消、和超时的理解

  • 主要功能:

    • 取消:父context被取消时,其派生的所有子context也会被取消。Goroutine可以监听context.Done()channel来优雅退出
    • 超时:可以设置context在特定时间点或一段时间后自动取消。
    • 值传递:可以在context中携带请求范围的数据(主要传递特定信息,如用户ID、Trace ID)。
  • 常见函数:

  • context.Background(), context.TODO(), context.WithCancel(), context.WithTimeout(), context.WithDeadline(), context.WithValue()
    
如何优雅地关闭一个channel并通知所有接受者?
  • 解读:考察channel的关闭机制和广播信号。
  • 思路:
    • 通过close(ch)关闭channel。
    • 关闭后,不能再向channel发送数据(会panic)
    • 接收者可以从已经关闭的channel接受数据,会接收到已缓冲的值,如何对应类型的零值。
    • 使用value,ok := <- ch语法判断channel是否已关闭
    • 对于多个接受者,通常是发送者负责关闭。
什么是竞态条件?如何检测和避免?
  • 解读:并发编程的核心问题之一
  • 思路:
    • 定义:多个Goroutine并发访问和修改共享资源,且最终结果依赖于这些操作的执行顺序
    • 检测:Go提供了内置的竞态检测器。编译时使用-race标志(go run -race main.go或go test -race)。
    • 避免:
      • 互斥锁:保护对共享资源的访问。
      • 原子操作(sync/atomic):对于基本数据类型进行原子读写
      • channel:通过通信共享数据,将对共享资源的访问限制在单个Goroutine中
      • 不可变数据:如果共享数据是不可变的,则不会发生竞态
sync.Once的使用场景是什么?
  • 解读:考察如何确保某个操作只执行一次。
  • 思路:
    • sync.Once是一个对象,它包含一个Do(f func())方法
    • Do方法会确保传入的函数f在所有的Goroutine中只执行一次,即使Do被多次调用
    • 场景:单例模式的初始化、全局资源的惰性初始化等。
实现一个生产者-消费者模型
  • 解读:综合考察channel、Goroutine和同步
  • 思路:
    • 生产者:生成数据并将其发送到channel
    • 消费者:从channel接受数据并处理
    • channel:作为生产者和消费者之间的缓冲区
    • 同步:
      • 生产者生产完毕后,需要通知消费者(例如关闭channel)
      • 主Goroutine可能需要等待生产者和消费者都完成
package main

import (
	"fmt"
	"time"
)

func producer(ch chan<- int) {
	for i := 1; i <= 5; i++ {
		ch <- i
		fmt.Println("生产者生产:", i)
		time.Sleep(time.Second)

	}
	close(ch)
}

func consumer(ch <-chan int, done chan<- bool) {
	for num := range ch {
		fmt.Println("消费者消费:", num)
		time.Sleep(2 * time.Second)
	}
	done <- true
}

func main() {

	ch := make(chan int, 3)

	done := make(chan bool)

	go producer(ch)
	go consumer(ch, done)
	<-done
	fmt.Println("done")

}

go map

map是一种key-value组成的哈希表,但是哈希表有一大问题就是哈希冲突,这在cpp中的解读就是在codeforces上敢用unorder_map绝对会被hack,因为哈希冲突会导致查询非常缓慢

那么go是如何解决这一难题:go map使用链地址法解决哈希冲突。当哈希冲突发生时,新的键值对会被添加到冲突位置对应的桶中,形成链表。以下是链地址法解决哈希冲突的步骤:

  • 计算哈希值
  • 确定桶位置
  • 遍历链表
  • 插入键值对:如果链表中不存在相同的键,则将键值对插入到链表的尾部;如果存在相同的键,则更新键值的对。

go slice

slice 实际上是一个结构体,包含三个字段:长度、容量、底层数组。

  • array是底层数组的指针
  • len是切片的长度
  • cap是底层数组的长度,也就是切片的最大容量,cap的值永远大于等于len的值

切片是对数组的封装,提供了对数组中部分连续片段的引用,在运行期间可以修改它的长度和范围。

  • [:] 表示截取全部的数组或切片作为新切片 [0:max)
  • [:high] 截取 [0,high) 的数组或切片作为新切片
  • [low:] 截取 [low,max) 的数组或切片作为新切片

在切片达到容量上限时,继续 append 操作会导致切片扩容。

微服务相关

什么是微服务

微服务架构是一种架构模式或一种架构风格,它提倡将单一应用程序划分成一组小的服务,每个服务运行在其独立的自己的进程中,服务之间互相协调、互相配合,为用户提供最终价值。

微服务之间是如何通讯的?

第一种是远程过程调用

也就是rpc,比如grpc,thrift,等等

有点是简单,常见,因为没有中间件代理,系统更简单

缺点是只支持请求/响应的模式,不支持别的,比如通知、请求/异步响应等

降低了可用性,因为客户端和服务端在请求过程中必须是可用的

第二种是消息

使用异步消息来做服务间的通信。服务间通过消息管道来交换信息,从而通信

比如rocketmq,kafka等

优点是把服务端和客户端解耦,更松耦合,提高可用性,因为消息中间件缓存了消息,直到消费者可以消费

支持很多通信机制比如通知、请求、异步响应、发步、订阅、发步、异步响应

缺点是消息中间件有额外的复杂度开销

微服务的优缺点

优点:

  • 优点是内聚足够小,代码容易理解
  • 开发简单,效率高
  • 微服务能被小团队独立开发
  • 微服务松耦合,是有可能意义的服务,无论是在开发阶段或部署阶段都是独立的
  • 微服务能使用不同的语言开发
  • 易于和第三方集成,微服务运行容易且灵活的方式集成自动部署,通过持续集成工具
  • 微服务容易被理解,便于修改和维护
  • 每个微服务都有自己的存储能力,可以有自己的数据库。也可以有统一数据库

缺点:

  • 开发人员要处理分布式系统的复杂性
  • 多服务运维难度,随着服务的增加,运维压力也在增大
  • 系统部署依赖
  • 服务间通信成本
  • 数据一致性
  • 系统集成测试
  • 性能监控

消息队列相关

什么是消息队列

消息队列是一个使用队列来通信的组件,它的本质就是一个转发器包含发消息、存消息、消费消息的过程。

我们通常说的消息队列,简称mq,它其实就是指消息中间件,当前业务比较流行的开源信息中间件包括rabbitmq,rocketmq,kafka。

消息队列有什么使用场景(为什么使用消息队列)

  • 应用解耦
  • 流量削峰
  • 异步处理
  • 消息通讯
  • 远程调用
生产者保证不丢失消息

生产端如何保证不丢失消息?确保生产的消息能达到存储端

如果是rocketmq消息中间件,producer生产者提供了三种发送消息的方式,分别是

  • 同步发送
  • 异步发送
  • 单向发送

生产者要想发消息时保证消息不丢失,可以:

  • 采用同步方式发送,send消息发送方法返回成功状态,就表示消息正常到达了存储端broker
  • 如果send消息异常或者返回非成功状态,可以重试
  • 可以使用事物消息,Rocketmq的事物消息机制就是为了保证零丢失来设计的

存储端不丢失消息

确保消息持久化到磁盘。大家很容易想到就是刷盘机制。

刷盘机制分同步刷盘和异步刷盘:

  • 生产者消息发过来时,只有持久化到磁盘,rocketmq的存储端broker才返回一个成功的ACK相应,这就是同步刷盘。它保证消息不丢失,但是影响了性能。
  • 异步刷盘的话。只要消息写入pagecache缓存,就返回一个成功的ACK相应

Broker一般是集群部署的,有master主节点和slave从节点。消息到Broker存储端,只有主节点和从节点都写入成功,才反馈成功的ack给生产者。这就是同步复制,它保证了消息不丢失,但是降低了系统的吞吐量。与之对应的就是异步复制,只要消息写入主节点成功,就返回成功的ack,它速度快,但是会有性能问题。

消息队列如何保证消息的顺序性

消息的有序性,就是指可以按照消息的发送顺序来消费。这些业务对消息的顺序是有要求的比如先下单再付款,最后再完成订单,这样等。假设生产者先后生产了两条消息,分别是下单消息,付款消息,M1比M2先产生,如何保证M1比M2先被消费呢

为了保证消息的顺序性,可以将将M1、M2发送到同一个Server上,当M1发送完收到ack后,M2再发送,这样还是可能会有问题,因为从MQ服务器到服务端,可能存在网络延迟,虽然M1先发送,但是它比M2晚到。那还能怎么办才能保证消息的顺序性呢?将M1和M2发往同一个消费者,且发送M1后,等到消费端ACK成功后,才发送M2就得了。

git:merge和rebase的区别

在git中,merge的原理就是将要合并分支的最新提交组合成一个新的提交,并且插入到目标分支中

merge会保留所有历史提交,不会破坏历史提交的结构,这一点在多人协作的时候很重要.

与merge不同的是,rebase并不会保留原有的提交,而是会创建当前分支比目标分支更新的所有提交的副本

但rebase会改变提交历史

merge和rebase最大的区别在于是否会保留原有的提交

merge会对提交历史进行保留,适合多人协作开发的场景,rebase简洁易读,更适合个人开发和整理分支情况

posted @ 2025-06-27 16:44  夏尾草  阅读(84)  评论(0)    收藏  举报