You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

54 KiB

如何评价Go语言

  • 简洁
    • 语法简洁没有传统语言的继承try-catch异常处理机制
    • 并发编程模式简单,通过通道控制
    • 但支持类型断言,泛型(1.18开始)
  • 并发
    • 采用混合调度模型
    • 采用通道进行数据同步
  • 内存安全
    • 自带垃圾回收功能
  • 良好的工具生态
    • 自带格式化工具
    • 内置性能调优诊断工具

Go的调度机制GMP模型

GMP指的是什么

  • GGoroutineGoroutine即协程为用户级的轻量级线程每个 Goroutine对象中的 sched 保存着其上下文信息(sp、pc等信息。G是参与调度与执行的最小单位是并发的关键。
  • MMachine是对内核级线程的抽象封装。M负责执行G。
  • PProcessor即为 G 和 M 的调度对象,用来调度 G 和 M 之间的关联关系,其数量可通过 GOMAXPROCS()或者GOMAXPROC环境变量来设置默认为核心数。Linux中P的数量是通过CPU亲和性的系统调用获取。每个P都拥有一个本地可运行G的队列(Local ruanble queue简称为LRQ)该队列最多可存放256个G。P的runnext字段也存放了一个G属于快速路径。

GMP调度流程

  • 每个P有个局部队列(LRQ)局部队列保存待执行的goroutine(流程2)当M绑定的P的的局部队列已经满了之后就会把goroutine放到全局队列(流程2-1)
  • 每个P和一个M绑定M是真正的执行P中goroutine的实体(流程3) M从绑定的P中的局部队列获取G来执行
  • 当M绑定的P的局部队列为空时M会从全局队列获取到本地队列来执行G(流程3.1)当从全局队列中没有获取到可执行的G时候M会从其他P的局部队列中偷取G来执行(流程3.2)这种从其他P偷的方式称为work stealing
  • 当G因系统调用阻塞(属于系统调用阻塞时会阻塞M此时P会和M解绑即hand off并寻找新的idle的M若没有idle的M就会新建一个M(流程5.1)
  • 当G因channel(属于用户态阻塞)或者network I/O阻塞时不会阻塞MM会寻找其他runnable的G当阻塞的G恢复后会重新进入runnable进入P队列等待执行(流程5.3)

work stealing 机制

获取 P 本地队列,当从绑定 P 本地 runq 上找不到可执行的 g尝试从全局链表中拿再拿不到从 netpoll 和事件池里拿,最后会从别的 P 里偷任务。P此时去唤醒一个 M。P 继续执行其它的程序。M 寻找是否有空闲的 P如果有则将该 G 对象移动到它本身。接下来 M 执行一个调度循环(调用 G 对象->执行->清理线程→继续找新的 Goroutine 执行。可以看出来work stealing机制包含了两阶段调度模型。

hand off 机制

当本线程 M 因为 G 进行的系统调用阻塞时,线程释放绑定的 P把 P 转移给其他空闲的 M 执行。

细节当发生上线文切换时需要对执行现场进行保护以便下次被调度执行时进行现场恢复。Go 调度器 M 的栈保存在 G 对象上,只需要将 M 所需要的寄存器SP、PC 等)保存到 G 对象上就可以实现现场保护。当这些寄存器数据被保护起来就随时可以做上下文切换了在中断之前把现场保存起来。如果此时G 任务还没有执行完M 可以将任务重新丢到 P 的任务队列等待下一次被调度执行。当再次被调度执行时M 通过访问 G 的 vdsoSP、vdsoPC 寄存器进行现场恢复(从上次中断位置继续执行)。

GMP 调度过程中存在哪些阻塞?

  • I/O其中网络层级IO已经实现用户级阻塞不会handleoff M)
  • block on syscall系统级阻塞会handoff M)
  • channel/select(用户级阻塞)
  • 等待锁
  • runtime.Gosched() (主动handoff M)

GMP 中为什么需要P?

GM 调度存在的问题:

  1. 单一全局互斥锁Sched.Lock和集中状态存储
  2. Goroutine 传递问题M 经常在 M 之间传递”可运行”的 goroutine
  3. 每个 M 做内存缓存,导致内存占用过高,数据局部性较差
  4. 频繁 syscall 调用,导致严重的线程阻塞/解锁,加剧额外的性能损耗

Go中协作式抢占式调度存在的问题以及后面如何解决了

Go1.14 版本之前Gorountine需要栈分裂时候才能触发调度。这种方式存在问题有

  • 某些 Goroutine 可以长时间占用线程,造成其它 Goroutine 的饥饿比如for循环
  • 垃圾回收需要暂停整个程序Stop-the-worldSTW最长可能需要几分钟的时间导致整个程序无法工作。

Go1.14之后开始支持基于信号的抢占式调度。为了防止执行信号的handle函数发生栈溢出每个Goroutine都有一个专门的信号栈。从细节来看具体原因是go1.14和go.1.13的调度器有一定的不同。

两者都支持抢占式调度当go runtime启动时候都会创建一个独立的M称为sysmon它既不关联P也不执行G它是系统级线程。sysmon会检查go runtime中长时间运行的G并进行抢占。

go1.13版本中sysmon如果发现某个G运行时间超过10ms就会将该G标记为可抢占状态此外G在运行过程中有一个函数栈分裂处理的逻辑该处理逻辑会查看其是否被标记为可抢占状态如果是那么其会让出其关联的M。

示例代码中for循序不会出现栈分裂的情况所以G即使运行超过10ms也不会被抢占。由于所有的P关联的G都运行着for死循环且不会被抢占那么就没有多余的P可以执行fmt.Println语句了。

由于go1.13是基于函数栈分裂实现的抢占式调度,所以也称为半抢占式调度(即未完全实现抢占式调度)或协作抢占式调度。

go1.14版本为了解决类似示例代码中问题引入了信号机制实现抢占式调度。sysmon发现某个G运行时间超过10ms就会给该G发送一个信号SIGURG该G收到抢占调度信号后会让出M。

Sysmon 有什么作用?

Go Runtime 在启动程序的时候,会创建一个独立的 M 作为监控线程,称为 sysmon它是一个系统级的 daemon 线程。这个sysmon 独立于 GPM 之外也就是说不需要P就可以运行。sysmon监控线程的功能有

  • 用于网络轮询器中唤醒准备就绪的fd关联的goroutine
  • 如果超过2分钟没有GC则强制执行GC一次
  • 抢占运行时间太长Goroutine超过10ms的g会进行retake)
  • handle off长时间运行系统调用的M即将M和P解绑P重新找到空闲的M执行任务若没有空闲的M则会创建一个。
  • 定时器与滴答器的调度处理
  • 打印schedule trace信息

defer 语法特点有哪些,底层实现机制?

概念

defer语法是用来定义一个延迟函数遵循LIFO顺序。defer在运行过程遵循下面三条官方规则

  • defer函数的传入参数在定义时就已经明确

    func main() {
    	i := 1
    	defer fmt.Println(i) // 只会打印出来1
    	i++
    	return
    }
    
  • defer函数是按照后进先出的顺序执行

    func main() {
    	for i := 1; i <= 5; i++ {
    		defer fmt.Print(i) // 依次输出54321
    	}
    }
    
  • defer函数可以读取和修改函数的命名返回值

    func main() {
    	fmt.Println(test()) // 输出101
    }
    
    func test() (i int) {
    	defer func() {
    		i++
    	}()
    	return 100
    }
    

原理

https://static.cyub.vip/images/202105/defer_profile.png

简单描述

  • 底层结构_defer结构体多个defer函数构成_defer链表后面的defer函数会插入链表头部最后该链表挂载到G上面执行时候从链表头部依次执行
  • 为了减少创建_defer结构体的内存分配Go采用了两层defer缓冲池分别为per-P级别这个是无锁的goroutine有限从当前P中取。剩下一个是全局的defer缓存。

详细描述

defer语法对应的底层数据结构是_defer结构体多个defer函数会构建成一个_defer链表后面加入的defer函数会插入链表的头部该链表链表头部会链接到G上。当函数执行完成返回的时候会从_defer链表头部开始依次执行defer函数。这也就是defer函数执行时会LIFO的原因。

创建_defer结构体是需要进行内存分配的为了减少分配_defer结构体时资源消耗Go底层使用了两级defer缓冲池defer pool用来缓存上次使用完的_defer结构体这样下次可以直接使用不必再重新分配内存了。defer缓冲池一共有两级per-P级defer缓冲池和全局defer缓冲池。当创建_defer结构体时候优先从当前M关联的P的缓冲池中取得_defer结构体即从per-P缓冲池中获取这个过程是无锁操作。如果per-P缓冲池中没有则在尝试从全局defer缓冲池获取若也没有获取到则重新分配一个新的_defer结构体。

测试题目:

func main() {
	for i := 1; i <= 5; i++ {
		defer fmt.Print(i) // 54321
	}
	fmt.Println(test1()) // 2
	fmt.Println(test2()) // 1
	fmt.Println(test3()) // 2
}

// 测试1
func test1() (i int) {
	i = 1
	defer func() {
		i = i + 1
	}()
	return i
}

func test2() (r int) {
	i := 1
	defer func() {
		i = i + 1
	}()
	return i
}

func test3() (r int) {
	defer func(r int) {
		r = r + 2
	}(r)
	return 2
}

适用场景

  • 用户资源的释放操作
  • 修改命名返回值
  • 和recover关键字一起用于panic捕获

select可以用于做什么?

通道选择器常用语gorotine的退出。golang 的 select 就是监听 IO 操作,当 IO 操作发生时触发相应的动作每个case语句里必须是一个IO操作确切的说应该是一个面向channel的IO操作。

  1. 监听exit通道
  2. or-done模式

映射

map是否是并发安全的如何实现顺序读取如何实现并发的map?

map中底层设计的知识点key长度过长会不会影响map的读写效率

https://static.cyub.vip/images/202106/map_access.png

访问映射涉及到key定位的问题首先需要确定从哪个桶找确定桶之后还需要确定key-value具体存放在哪个单元里面每个桶里面有8个坑位。key定位详细流程如下

  1. 首先需根据hash函数计算出key的hash值
  2. 该key的hash值的低hmap.B位的值是该key所在的桶
  3. 该key的hash值的高8位用来快速定位其在桶具体位置。一个桶中存放8个key遍历所有key找到等于该key的位置此位置对应的就是值所在位置
  4. 根据步骤3取到的值计算该值的hash再次比较若相等则定位成功。否则重复步骤3去bmap.overflow中继续查找。
  5. bmap.overflow链表都找个遍都没有找到则返回nil。

删除map中元素时候并不会释放内存。删除时候会清空映射中相应位置的key和value数据并将对应的tophash置为emptyOne。此外会检查当前单元旁边单元的状态是否也是空状态如果也是空状态那么会将当前单元和旁边空单元状态都改成emptyRest。

Go语言中映射扩容采用渐进式扩容避免一次性迁移数据过多造成性能问题。当对映射进行新增、更新时候会触发扩容操作然后进行扩容操作删除操作只会进行扩容操作不会进行触发扩容操作每次最多迁移2个bucket。扩容方式有两种类型

  1. 等容量扩容
  2. 双倍容量扩容

sync.Map的适合场景如果是写多读少且支持并发怎么设计

sync.Map适用于读多写少的场景。对于写多的场景会导致 read map 缓存失效,需要加锁,导致冲突变多;而且由于未命中 read map 次数过多,导致 dirty map 提升为 read map这是一个 O(N) 的操作,会进一步降低性能。

  • sync.Map采用空间换时间策略。其底层结构存在两个map分别是read map和dirty map。当读取操作时候优先从read map中读取是不需要加锁的若key不存在read map中时候再从dirty map中读取这个过程是加锁的。当新增key操作时候只会将新增key添加到dirty map中此操作是加锁的但不会影响read map的读操作。当更新key操作时候如果key已存在read map中时候只需无锁更新更新read map就行同时负责加锁处理在dirty map中情况了。总之sync.Map会优先从read map中读取、更新、删除因为对read map的读取不需要锁
  • 当sync.Map读取key操作时候若从read map中一直未读到若dirty map中存在read map中不存在的keys时则会把dirty map升级为read map这个过程是加锁的。这样下次读取时候只需要考虑从read map读取且读取过程是无锁的

为什么不使用sync.Mutex+map实现并发的map呢

这个问题可以换个问法就是sync.Map相比sync.Mutex+map实现并发map有哪些优势

sync.Map优势在于当key存在read map时候如果进行Store操作可以使用原子性操作更新而sync.Mutex+map形式每次写操作都要加锁这个成本更高。

另外并发读写两个不同的key时候写操作需要加锁而读操作是不需要加锁的。

通道

channel有哪几种类型有哪些特点底层数据是怎么样的是否是并发安全的以及怎么做到并发安全的

channel收发遵循FIFO原则其底层是hchan结构指针创建通道使用make关键字。对于有缓存的通道其底层是固定大小的循环队列。由于对通道读取、写入时候会加锁所以是并发安全的。当channel因为缓冲区不足而阻塞队列时候则使用双向链表存储。Go语言中不要通过共享内存来通信而要通过通信实现内存共享。Go的CSP(Communicating Sequential Process)并发模型,中文可以叫做通信顺序进程,是通过 goroutine 和 channel 来实现的。

通道类型有:

  • 有缓存通道/无缓冲通道
  • 读写通道/只读通道/只写通道

特点有:

  • 读写nil通道永远阻塞。关闭nil通道会panic
  • 读一个已关闭的通道如果缓存区为空时候则返回一个零值。可以使用for-range或者逗号ok
  • 写一个已关闭的通道会panic

内置的cap函数可以用于哪些内容

  • array
  • slice
  • channel

为啥 channel 会有 close 这个操作, 在哪些场景下会用到这个操作 ?

在 Go 语言中channel 的 close 操作用于向 channel 的接收方明确地通知发送操作已经完成。关闭一个 channel 可以表达“没有更多的数据将被发送到这个 channel”这一信号。这是一种控制信号帮助接收方理解数据流的生命周期并且可以避免在 channel 上进行无限等待。

使用 close 的场景

  1. 通知多个接收者完成处理

    当使用一个 channel 来分发任务或数据给多个协程goroutines关闭 channel 是一种告知所有接收者没有更多数据要处理的有效方法。接收者可以通过检测 channel 是否已关闭来适时停止处理。

  2. 控制循环退出

    在接收数据时,可以使用 for range 循环从 channel 接收数据。当 channel 被关闭,并且 channel 中已经没有待处理的数据时for range 循环会自动结束。这使得编码简洁,并且逻辑清晰。

  3. 防止资源泄露

    如果不关闭不再使用的 channel可能会导致内存资源没有得到释放特别是在 channel 还保持着一些数据项的情况下。尽管 Go 的垃圾回收机制会回收未引用的对象,但显式关闭 channel 是一个好的实践,它可以清晰地表达程序设计者的意图。

  4. 使用 select 的默认操作

    在使用 select 语句处理多个 channel 的时候,关闭一个 channel 可以用于触发其他 case 的执行。特别是在一些需要优雅退出的并发模式中,关闭 channel 可以促使 select 快速响应并处理结束逻辑。

示例:数据处理和广播信号

假设有一个数据处理任务,需要将数据分批发送到多个处理协程,处理完成后再汇总结果。这里可以使用关闭 channel 的方式来告知所有处理协程,数据已经发送完毕:

func processData(dataChunks [][]int) []int {
    var results []int
    resultChan := make(chan int)
    dataChan := make(chan int, 100)

    // 启动多个工作协程
    for i := 0; i < 5; i++ {
        go func() {
            for data := range dataChan {
                result := process(data) // 假设有一个处理函数
                resultChan <- result
            }
        }()
    }

    // 发送数据
    go func() {
        for _, chunk := range dataChunks {
            for _, data := range chunk {
                dataChan <- data
            }
        }
        close(dataChan)
    }()

    // 接收结果
    go func() {
        for i := 0; i < len(dataChunks); i++ {
            result := <-resultChan
            results = append(results, result)
        }
        close(resultChan)
    }()

    return results
}

在这个示例中,通过关闭 dataChan 来告知工作协程不会再有新的数据发送,这时协程可以结束从 channel 接收数据的操作。关闭 resultChan 则用来表示所有结果已经处理完毕,可以进行后续步骤。

总结来说,关闭一个 channel 是一种向接收方传递完成信号的方法,它在多协程协作的环境中尤为有用,有助于提高代码的可读性和安全性。

Go如何避免内存的对象频繁分配和回收的问题

可以考虑使用对象缓存池sync.Pool

Go如何进行并发竞态检测如何避免竞态问题

Go支持go run/test/build 使用-race选项进行竞态检查。可以使用锁、信号量等同步手段保护临界区或者原子操作等手段避免竞态问题。

如何实现循环队列?

channel或者atomic实现。

锁种类

  • 写锁-sync.Mutex属于排他锁或互斥锁
  • 读写锁-sync.RWMutex属于共享锁

这两种锁的对象单元都是goroutine底层用到类似信号机制。在runtime时也有mutex锁底层使用futex系统调用锁的对象是线程M它还会阻止相关联的 G 和 P 被重新调度。

所有锁使用时候需要指针传递也就是nocopy机制。此外Go内置的锁也不是可重入的。

sync.Mutex的工作模式

Mutex 一共有下面几种状态:

  • mutexLocked — 表示互斥锁的锁定状态;
  • mutexWoken — 表示从正常模式被从唤醒;
  • mutexStarving — 当前的互斥锁进入饥饿状态;
  • waitersCount — 当前互斥锁上等待的 Goroutine 个数;

正常模式和饥饿模式:

对于两种模式正常模式下的性能是最好的goroutine 可以连续多次获取锁,饥饿模式解决了取锁公平的问题,但是性能会下降,这其实是性能和公平的一个平衡模式。

  • 正常模式(非公平锁)

    正常模式下,所有等待锁的 goroutine 按照 FIFO先进先出顺序等待。唤醒的 goroutine 不会直接拥有锁,而是会和新请求 goroutine 竞争锁。新请求的goroutine 更容易抢占:因为它正在 CPU 上执行,所以刚刚唤醒的 goroutine有很大可能在锁竞争中失败。在这种情况下这个被唤醒的 goroutine 会加入到等待队列的前面。

  • 饥饿模式(公平锁)

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

饥饿模式的触发条件:当一个 goroutine 等待锁时间超过 1 毫秒时,或者当前队列只剩下一个 goroutine 的时候Mutex 切换到饥饿模式。

Mutex运行自旋的条件有

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

RWMutex实现原理以及在使用过程中需要注意事项

RWMutex是读写锁用于解决读者-写者问题,并且是写者优先的锁。如果有写者提出申请资源,在申请之前已经开始读取操作的可以继续执行读取,但是如果再有读者申请读取操作,则不能够读取,只有在所有的写者写完之后才可以读取。写者优先解决了读者优先造成写饥饿的问题

type RWMutex struct {
	w           Mutex  // 互斥锁
	writerSem   uint32 // writers信号量
	readerSem   uint32 // readers信号量
	readerCount int32  // reader数量
	readerWait  int32  // writer申请锁时候已经申请到锁的reader的数量
}

对于读者优先readers-preference的读写锁只需要一个readerCount记录所有读者,就可以轻易实现。Go中的RWMutex实现的是写者优先writers-preference的读写锁,那就需要用到readerWait来记录写者申请锁时候,已经获取到锁的读者数量。

这样当后续有其他读者继续申请锁时候可以读取readerWait是否大于0大于0则说明有写者已经申请锁了按照写者优先writers-preference原则该读者需要排到写者之后但是我们还需要记录这些排在写者后面读者的数量呀毕竟写着将来释放锁的时候还得一个个唤醒这些读者。这种情况下既要读取readerWait又要更新排队的读者数量readerCount这是两个操作无法原子化。RWMutex在实现时候通过将readerCount转换成负数一方面表明有写者申请了锁另一方面readerCount还可以继续记录排队的读者数量解决刚描述的无法原子化的问题真是巧妙

错误的使用场景:

  • RLock/RUnlock、Lock/Unlock未成对出现
  • 复制sync.RWMutex作为函数值传递
  • 不可重入导致死锁

sync.WaitGroup用法以及实现原理

sync.WaitGroup用于等待一组协程完成。

sync.WaitGroup维护了2个计数器一个是请求计数器每次执行Add时候该计数器会加1另外一个是等待计数器每次执行Wait时候该计数器会加1。当执行Done时候会将请求计数器减一当请求计数器为0时候会唤醒等待的等待者。

需要注意的时候Add()和Wait() 不能并发调用。

sync.Once用法

sync.Once用来执行且执行一次动作常常用于单例对象初始化场景。

什么是CAS?

CAS全称为Compare And Swap中文翻译为比较交换是一条原子指令对应cmpxchg指令其原理是先比较两个值是否相等然后原子地更新某个位置的值。基于CAS我们可以实现一个自旋锁无锁堆栈。基于CAS实现的无锁数据结构中需要注意ABA问题

sync.Pool的用法以及实现原理

频繁地分配回收内存会给GC带来一定负担严重时候会引起CPU的毛刺现象而通过sync.Pool可以将暂时不用的对象缓存起来等下次需要时候直接使用不用再次经过内存分配复用对象的内存减轻GC的压力提升系统的性能。

sync.Pool提供了临时对象缓存池存在池子的对象可能在任何时刻被自动移除我们对此不能做任何预期。sync.Pool可以并发使用它通过复用对象来减少对象内存分配和GC的压力。当负载大的时候临时对象缓存池会扩大缓存池中的对象会在每2个GC循环中清除。

sync.Pool拥有两个对象存储容器local pool和victim cache。local pool与victim cache相似相当于primary cache。当获取对象时优先从local pool中查找若未找到则再从victim cache中查找若也未获取到则调用New方法创建一个对象返回。当对象放回sync.Pool时候会放在local pool中。当GC开始时候首先将victim cache中所有对象清除然后将local pool容器中所有对象都会移动到victim cache中所以说缓存池中的对象会在每2个GC循环中清除。

若G关联的per-P级poolLocal的双端队列中没有取出来对象那么就尝试从其他P关联的poolLocal中偷一个。若从其他P关联的poolLocal没有偷到一个那么就尝试从victim cache中取。

若步骤4中也没没有取到缓存对象那么只能调用pool.New方法新创建一个对象。

如何避免死锁?

死锁检测,活锁,银行家算法

Go中内存逃逸是怎么回事怎么检测内存逃逸有哪些内存逃逸的场景

Go 语言中决定一个变量分配栈上还是堆是Go编译器决定的如果变量分配到堆上那么我们就说着变量发生了逃逸。我们设置-gcflags=”-m”来检测内存逃逸。内存逃逸的场景一般有

  1. 函数返回局部变量的指针(一般会,并不绝对)
  2. 闭包中捕获变量会发生更改时候
  3. 切片变量过大时候

实现一个并发安全的set

type inter interface{}
type Set struct {
m map[inter]bool
sync.RWMutex
}

func New() *Set {
return &Set{
m: map[inter]bool{},
}
}
func (s *Set) Add(item inter) {
s.Lock()
defer s.Unlock()
s.m[item] = true
}

主协程如何等其余协程完再操作?

sync.Waitgroup

struct结构能不能比较

这个设计到Go语言中可比较性规则

  • 切片、映射、函数不可比较但都可以和nil比较

  • 当通道元素类型一样时候,可以比较,即使缓冲大小不一样

  • 指针类型只有指向的变量的类型一样时候才能够比较。但都可以和nil比较

  • 接口类型都可以相互比较,只有底层类型和底层值一样时候,才会相等

  • 数组类型,只有元素类型和数组大小一样时候,才可以进行比较

  • 如果结构体中所有字段都是可以比较的,那么该结构体就是可以比较的。注意:字段比较时候需要按照相同顺序依次比较。

    var t1 = struct {
    		A string
    		B string
    	}{}
    	var t2 = struct {
    		B string
    		A string
    	}{}
    	var t3 = struct {
    		A string
    		B string
    		c int // unexport
    	}{}
    	fmt.Println(t1 == t2) // 不能比较
    	fmt.Println(t1 == t3) // 不能比较
    
    // invalid operation: t1 == t2 (mismatched types struct{A string; B string} and struct{B string; A string})
    // invalid operation: t1 == t3 (mismatched types struct{A string; B string} and struct{A string; B string; c int})
    

Go里面的值传递和指针传递

函数参数传递方式一般有两种:值传递和引用传递。其中值传递中可以传递指针,这种情况可以称为指针传递。指针传递不等于引用传递,尽管两者都可以改变原始值。

Go语言中所有都是值传递。切片通道映射属于指针传递因为它们底层是一个指针(或者是胖指针)

context包的用途

context.Context的作用就是在不同的goroutine之间同步请求特定数据、取消信号以及处理请求的截止日期。

字符串有哪几种拼接方式?性能怎么样?

字符串底层结构本质是一个fat-pointer:

type StringHeader struct {
	Data uintptr
	Len  int
}
  • +号拼接,会产生临时字符串,性能一般
  • fmt.Printf 进行拼接由于字符串会变成interface{},产生内存逃逸,性能较差
  • strings.Join 用于字符串切片拼接底层用到了strings.Builder性能比较高
  • strings.Builder 性能高底层用到内存缓冲内存缓冲结构是字节切片输出字符串时候使用了zero-copy技术直接把字节切片转换成字符串。缺点就是每次reset时候都会将内存缓冲至为nil不能够复用
  • bytes.Buffer 性能高跟strings.Builder类似但reset时候不会将内存缓冲至为nil能够达到复用的目的

Go 数组与C语言数组有什么区别

Go语言中数组是一片连续的内存一个值类型作为参数传递时候会把COPY旧数组形成一个新数组作为函数的参数。这也意味着在函数内改变数组值不会影响原数组。

slice的len,cap知识底层共享等问题以及扩容策略

切片概念

Go中切片是动态数组的概念底层结构类似字符串但其指针指向的内存是可以更改的并且它还有一个容量字段。

type slice struct {
	array unsafe.Pointer // 底层数据数组的指针
	len   int // 切片长度
	cap   int // 切片容量
}

切片作为参数传递时候也是值传递,但它传递的是指针,属于指针传递,所以它拥有引用传递的特性

为了避免切片指针传递带来的副作用可以使用内置copy函数复制一个全新的切片再传递。

创建方式

切片的创建方式有:

  1. 使用make关键字创建形式make([]T, length, capacity)capacity可以省略默认等于length

  2. 基于数组,指向数组的指针,切片构建一个切片

    reslice操作语法可以是[]T[low : high],也可以是[]T[low : high : max]。其中low,high,max都可以省略low默认值是0high默认值cap([]T)max默认值cap([]T)。low,hight,max取值范围是0 <= low <= high <= max <= cap([]T) 其中high-low是新切片的长度max-low是新切片的容量。

    对于[]T[low : high],其包含的元素是[]T中下标low开始到high结束不含high所在位置的相当于左闭右开[low, high)的元素元素个数是high - low个容量是cap([]T) - low。

  3. 使用字面量创建

reslice

基于切片或者数组reslice一个新切片时候需要注意新切片的容量

func main() {
	slice1 := make([]int, 0)
	slice2 := make([]int, 1, 3)
	slice3 := []int{}
	slice4 := []int{1: 2, 3}
	arr := []int{1, 2, 3}
	slice5 := arr[1:2]
	slice6 := arr[1:2:2]
	slice7 := arr[1:]
	slice8 := arr[:1]
	slice9 := arr[3:]
	slice10 := slice2[1:2]
	fmt.Printf("%s = %v,\t len = %d, cap = %d\n", "slice1", slice1, len(slice1), cap(slice1))
	fmt.Printf("%s = %v,\t len = %d, cap = %d\n", "slice2", slice2, len(slice2), cap(slice2))
	fmt.Printf("%s = %v,\t len = %d, cap = %d\n", "slice3", slice3, len(slice3), cap(slice3))
	fmt.Printf("%s = %v,\t len = %d, cap = %d\n", "slice4", slice4, len(slice4), cap(slice4))
	fmt.Printf("%s = %v,\t len = %d, cap = %d\n", "slice5", slice5, len(slice5), cap(slice5))
	fmt.Printf("%s = %v,\t len = %d, cap = %d\n", "slice6", slice6, len(slice6), cap(slice6))
	fmt.Printf("%s = %v,\t len = %d, cap = %d\n", "slice7", slice7, len(slice7), cap(slice7))
	fmt.Printf("%s = %v,\t len = %d, cap = %d\n", "slice8", slice8, len(slice8), cap(slice8))
	fmt.Printf("%s = %v,\t len = %d, cap = %d\n", "slice9", slice9, len(slice9), cap(slice9))
	fmt.Printf("%s = %v,\t len = %d, cap = %d\n", "slice10", slice10, len(slice10), cap(slice10))
}

上面输出:

slice1 = [],	 len = 0, cap = 0
slice2 = [0],	 len = 1, cap = 3
slice3 = [],	 len = 0, cap = 0
slice4 = [0 2 3],	 len = 3, cap = 3
slice5 = [2],	 len = 1, cap = 2
slice6 = [2],	 len = 1, cap = 1
slice7 = [2 3],	 len = 2, cap = 2
slice8 = [1],	 len = 1, cap = 3
slice9 = [],	 len = 0, cap = 0
slice10 = [0],	 len = 1, cap = 2

扩容策略

切片的扩容策略是:

  1. 首先判断,如果新申请容量大于 2 倍的旧容量,最终容量就是新申请的容量
  2. 否则判断,如果旧切片的长度小于 1024则最终容量就是旧容量的两倍
  3. 否则判断,如果旧切片长度大于等于 1024则最终容量从旧容量开始循环增加原来的 1/4, 直到最终容量大于等于新申请的容量。由于考虑内存对齐最终实际扩容大小可能会大于1/4

常见用法

//copy
b = make([]T, len(a))
copy(b, a)

//cut
a = append(a[:i], a[j:]...)

//delte
a = append(a[:i], a[i+1:]...)
// or
a = a[:i+copy(a[i:], a[i+1:])]

// insert
s = append(s, 0)
copy(s[i+1:], s[i:])
s[i] = x

//pop
x, a = a[len(a)-1], a[:len(a)-1]

//push
a = append(a, x)

//shift
x, a := a[0], a[1:]

//unshift
a = append([]T{x}, a...)

//反转
for left, right := 0, len(a)-1; left < right; left, right = left+1, right-1 {
	a[left], a[right] = a[right], a[left]
}

字符串与切片内存zero-copy转换的实现

func bytes2string(b []byte) string{
    return *(*string)(unsafe.Pointer(&b))
}

func StringToBytes(s string) (b []byte) {
	sh := *(*reflect.StringHeader)(unsafe.Pointer(&s))
	bh := (*reflect.SliceHeader)(unsafe.Pointer(&b))
	bh.Data, bh.Len, bh.Cap = sh.Data, sh.Len, sh.Len
	return b
}

func StringToBytes(s string) []byte {
	return *(*[]byte)(unsafe.Pointer(
		&struct {
			string
			Cap int
		}{s, len(s)},
	))
}

make与new的区别

  • Go 中make关键字用来创建切片通道映射返回是引用类型本身new返回的是指向类型的指针。new返回的类型指针指向的值为该类型的零值。由于new不会初始化内存只是清零内存所以new切片通道映射之后并不能直接使用
type User struct {
	name string
}

func main() {
	puser := new(User)
	puser.name = "hello"
	fmt.Println(*puser)
	
	pint := new(int)
	*pint = 123
	fmt.Println(*pint) // 123

	parr := new([5]int)
	(*parr)[1] = 123
	fmt.Println(parr) // &[0 123 0 0 0]

	pslice := new([]int)
	(*pslice)[0] = 8 // /panic: runtime error: index out of range
	
	pmap := new(map[string]string)
	(*pmap)["a"] = "a" // panic: assignment to entry in nil map
	
	pchan := new(chan string)
	pchan <- "good" //invalid operation: cv <- "good" (send to non-chan type *chan string)
}

nil 的概念

对应于引用类型的变量它的零值是nil。零值指的是当声明变量且未显示初始化时Go语言会自动给变量赋予一个默认初始值。

  • 对nil通道读写操作会永远阻塞
  • 对nil切片可以append操作读写会panic
  • 对nil映射读取和删除ok写入会panic
  • nil可以作为接收者只不是值为nil而已

Go语言中指针与非安全指针类型概念

对于任意类型T其对应的的指针类型是T类型T称为指针类型T的基类型。 一个指针类型T变量B存储的是类型T变量A的内存地址我们称该指针类型变量B引用(reference)了A。从指针类型变量B获取或者称为访问A变量的值的过程叫解引用 。解引用是通过解引用操作符操作的。

Go中unsafe.Pointer是非安全类型指针它作为桥梁用于任意类型指针与uintptr互换。

type MyInt int

func main() {
	a := 100
	fmt.Printf("%p\n", &a)
	fmt.Printf("%x\n", uintptr(unsafe.Pointer(&a)))
}

三色标记法原理

Golang中采用 三色标记清除算法tricolor mark-and-sweep algorithm 进行GC。由于支持写屏障write barrier)了GC过程和程序可以并发运行。

三色标记清除算核心原则就是根据每个对象的颜色,分到不同的颜色集合中,对象的颜色是在标记阶段完成的。三色是黑白灰三种颜色,每种颜色的集合都有特别的含义:

  • 黑色集合

    该集合下的对象没有引用任何白色对象(即该对象没有指针指向白色对象)

  • 白色集合

    扫描标记结束之后,白色集合里面的对象就是要进行垃圾回收的,该对象允许有指针指向黑色对象。

  • 灰色集合

    可能有指针指向白色对象。它是一个中间状态,只有该集合下不在存在任何对象时候,才能进行最终的清除操作。

GC流程

当垃圾回收开始,全部对象标记为白色。

  • 垃圾回收器会遍历所有根对象并把它们标记为灰色,放入灰色集合里面。根对象就是程序能直接访问到的对象,包括全局变量以及栈、寄存器上的里面的变量。
  • 遍历灰色集合中的对象,把灰色对象引用的白色集合的对象放入到灰色集合中,同时把遍历过的灰色集合中的对象放到黑色的集合中
  • 重复步骤2直到灰色集合没有对象
  • 步骤3结束之后白色集合中的对象就是不可达对象也就是垃圾可以进行回收

为了支持能够并发进行垃圾回收Golang在垃圾回收过程中采用写屏障每次堆中的指针被修改时候写屏障都会执行写屏障会将该指针指向的对象标记为灰色然后放入灰色集合因为才对象现在是可触达的了然后继续扫描该对象。

举个例子说明写屏障的重要性:

假定标记完成的瞬间A对象是黑色B是白色然后A的对象指针字段f由空指针改成指向B若没有写屏障的话清除阶段B就会被清除掉那边A的f字段就变成了悬浮指针这是有问题的。若存在写屏障那么f字段改变的时候f指向的B就会放入到灰色集合中然后继续扫描B最终也会变成黑色的那么清除阶段它也就不会被清除了。

除了三色标记法外还有标记清除法标记清除法的最大弊端就是在整个GC期间需要STW。

虽然 golang 是先实现的插入写屏障,后实现的混合写屏障,但是从理解上,应该是先理解删除写屏障,后理解混合写屏障会更容易理解;

插入写屏障没有完全保证完整的强三色不变式(栈对象的影响),所以赋值器是灰色赋值器,最后必须 STW 重新扫描栈;

混合写屏障消除了所有的 STW实现的是黑色赋值器不用 STW 扫描栈;

混合写屏障的精度和删除写屏障的一致,比以前插入写屏障要低;

混合写屏障扫描栈式逐个暂停,逐个扫描的,对于单个 goroutine 来说,栈要么全灰,要么全黑;

暂停机制通过复用 goroutine 抢占调度机制来实现;

详细总结: Golang GC、三色标记、混合写屏障机制

golang GC工作过程

写屏障是什么_Golang 混合写屏障原理深入剖析,这篇文章给你梳理的明明白白!

两万字长文带你深入Go语言GC源码

强三色不变式规则:不允许黑色对象引用白色对象

破坏了条件一: 白色对象被黑色对象引用

解释:如果一个黑色对象不直接引用白色对象,那么就不会出现白色对象扫描不到,从而被当做垃圾回收掉的尴尬。

弱三色不变式规则:黑色对象可以引用白色对象,但是白色对象的上游必须存在灰色对象

破坏了条件二:灰色对象与白色对象之间的可达关系遭到破坏

解释: 如果一个白色对象的上游有灰色对象,则这个白色对象一定可以扫描到,从而不被回收

混合写屏障的具体核心规则如下:

  1. GC开始后先将栈上的可达对象全部扫描并标记为黑色(之后不再进行第二次重复扫描无需STW)

  2. GC期间任何在栈上创建的新对象均为黑色。

  3. (堆上)被删除的对象标记为灰色。

4.(堆上)被添加的对象标记为灰色。

场景一栈对象A的下游引用一个堆对象C接着该堆对象C被引用它的堆对象B删除。

  • 栈A引用即指向)对象C由于没有写屏障C对象不会做任何更改
  • 堆对象B删除掉引用C由于堆上删除写屏障的存在那么C如果是灰色和白色的那C就会标记成灰色

GC触发时机

  1. 主动触发

    调用runtime.GC

  2. 内存分配至时候被动触发

    由mallocgc()发起的触发条件是堆大小达到或者超过了临界值。使用步调Pacing算法其核心思想是控制内存增长的比例。如 Go 的 GC是一种比例 GC, 下一次 GC 结束时的堆大小和上一次 GC 存活堆大小成比例.

  3. 基于时间的周期性触发

    由系统监控sysmon发起该触发条件由 runtime.forcegcperiod 变量控制,默认为 2 分钟。当超过两分钟没有产生任何 GC 时,强制触发 GC。

辅助GC的目的是

辅助GC是mallocgc()函数的一部分mallocgc()函数式堆分配的关键函数runtime中new系列函数和make系列函数都依赖它。mallocgc()只有在GC标记阶段才执行辅助GC并且每个goroutine都已辅助GC的字节额度超过就不行辅助GC了。辅助GC机制能够优有限避免程序过快地分配内存从而造成GC工作线程(gc worker)来不及标记的问题。

GC如何调优

通过 go tool pprof 和 go tool trace 等工具

  • 控制内存分配的速度,限制 Goroutine 的数量,从而提高赋值器对 CPU

的利用率。

  • 减少并复用内存,例如使用 sync.Pool 来复用需要频繁创建临时对象,例

如提前分配足够的内存来降低多余的拷贝。

  • 需要时,增大 GOGC 的值,降低 GC 的运行频率。
  • 对于预分配的大量内存,则可能需要将 debug.SetGCPercent() 设置为低得多的百分比才能获得正常的 GC 频率。

reflect反射三定律

  1. Reflection goes from interface value to reflection object

    反射可以将“接口类型变量”转换为“反射类型对象”

  2. Reflection goes from reflection object to interface value

    反射可以将“反射类型对象”转换为“接口类型变量”

  3. To modify a reflection object, the value must be settable

    如果要修改“反射类型对象”其值必须是“可写的”settable

Go pprof

pprof支持以下几种分析器

  • Go 分析器

    CPU 分析器通过操作系统监控应用程序的CPU 使用情况并且每隔10ms的CPU 片时间发送一个SIGPROF信号来捕获profile数据。操作系统还包括内核在此监控中代表应用程序消耗的时间。由于信号传输速率取决于 CPU 消耗,因此它是动态的,最高可达 N * ``100Hz其中 N是操作系统上逻辑 CPU 内核的数量。当 SIGPROF信号到达时Go 的信号处理程序捕获当前活动的 goroutine 的堆栈跟踪并增加profile文件中的相应值。 cpu/nanoseconds值目前是直接从samples/count样本计数中推导出来的所以是多余的但是使用方便。

  • 内存分析器

  • 阻塞分析器

    Go 中的阻塞分析器衡量你的 goroutine 在等待通道以及sync包提供的互斥操作时在 Off-CPU 外花费的时间。以下 Go 操作会被阻塞分析器捕获分析:

    阻塞 profile文件不包括等待 I/O、睡眠、GC 和各种其他等待状态的时间。此外阻塞事件在完成之前不会被记录因此阻塞profile文件不能用于调试 Go 程序当前挂起的原因。后者可以使用 Goroutine 分析器确定。

Go内存分配原理

Golang内存分配管理策略是按照不同大小的对象和不同的内存层级来分配管理内存。通过这种多层级分配策略,形成无锁化或者降低锁的粒度,以及尽量减少内存碎片,来提高内存分配效率。

Golang中内存分配管理的对象按照大小可以分为

类别 大小
微对象 tiny object (0, 16B)
小对象 small object [16B, 32KB]
大对象 large object (32KB, +∞)

Golang中内存管理的层级从最下到最上可以分为mspan -> mcache -> mcentral -> mheap -> heapArena。golang中对象的内存分配流程如下

  1. 小于16个字节的对象使用mcache的微对象分配器进行分配内存
  2. 大小在16个字节到32k字节之间的对象首先计算出需要使用的span大小规格,然后使用mcache中相同大小规格的mspan分配
  3. 如果对应的大小规格在mcache中没有可用的mspan,则向mcentral申请
  4. 如果mcentral中没有可用的mspan,则向mheap申请并根据BestFit算法找到最合适的mspan。如果申请到的mspan超出申请大小,将会根据需求进行切分,以返回用户所需的页数,剩余的页构成一个新的mspan放回mheap的空闲列表
  5. 如果mheap中没有可用span,则向操作系统申请一系列新的页(最小 1MB
  6. 对于大于32K的大对象直接从mheap分配

mspan:

mspan是一个双向链表结构。mspan是golang中内存分配管理的基本单位。span大小一共有67个规格。规格列表如下 其中class = 0 是特殊的span用于大于32kb对象分配是直接从mheap上分配的。

mcache:

mcache持有一系列不同大小的mspan。mcache属于per-P cache由于M运行G时候必须绑定一个P这样当G中申请从mcache分配对象内存时候无需加锁处理。

mcetral:

当mcache的中没有可用的span时候会向mcentral申请。

Go错误处理

为了不丢失函数调用的错误链,使用fmt.Errorf时搭配使用特殊的格式化动词%w,可以实现基于已有的错误再包装得到一个新的错误。

fmt.Errorf("查询数据库失败err:%w", err)

对于这种二次包装的错误,errors包中提供了以下三个方法。

func Unwrap(err error) error                 // 获得err包含下一层错误
func Is(err, target error) bool              // 判断err是否包含target
func As(err error, target interface{}) bool  // 判断err是否为target类型

一篇文章带你轻松搞懂Golang的error处理_Golang_脚本之家

Go错误处理机制为啥不采用Java的try-catch的异常机制

Go 语言选择不使用 Java 中的异常机制而是采用返回错误error的方式处理异常情况这主要是基于几个设计目标和考虑

  • 简明性和可预测性:在 Go 语言中,错误被视为常见的、可预料的结果,而不是异常情况。通过显式地返回错误,程序员被迫处理错误,这可以使得错误处理更加显式和清晰,减少了忽视错误处理的可能性。

  • 控制流简单化:使用异常机制时,代码的执行流可以因为异常的抛出而在任何点被中断,这使得程序的控制流变得复杂和难以跟踪。相反,通过返回错误,错误处理和正常逻辑清晰地分离,控制流更加直观。

  • 性能考虑:异常处理机制通常涉及栈解 unwinding 过程这在性能上可能带来开销。Go 语言通过简单的值返回避免了这种开销。

  • 交叉语言调用简化Go 语言在设计时考虑到了与 C 语言的互操作性。在 C 语言中没有异常机制使用错误码是常见做法。Go 的错误处理方式与之兼容,简化了跨语言调用的复杂性。

关于是否是一个好的设计,这取决于具体的使用场景和个人偏好。这种设计强化了错误处理的重要性,并鼓励开发者更加关注程序可能出现的错误情况,从而可能增强程序的健壮性。但是,这也可能导致代码中充满大量的错误处理逻辑,有时可能显得冗余。

Java 的异常机制有其优点,特别是在错误需要跨多个调用层级传播时,异常提供了一种非常便捷的方式。然而,它也有一些常见的问题:

  • 异常被忽略Java 中的异常可以被捕获后不做任何处理,这可能隐藏程序中的错误。
  • 代码复杂性:异常的抛出和捕获可能使得程序逻辑变得难以理解和维护,特别是当异常可以在多个层级被抛出和捕获时。
  • 性能问题:异常处理可能引入性能损耗,特别是在异常频繁抛出的场景中。

总体来说Go 的错误处理方式倾向于更加显式和可控,而 Java 的异常机制则提供了一种灵活但有时可能不够清晰的错误处理策略。选择哪种方式更好,很大程度上取决于特定项目的需求和开发团队的偏好。

Go为什么采用组合机制而不是使用继承机制

Go 语言没有传统意义上的类和继承机制,这是其设计哲学中的一部分,旨在简化语言和避免常见的面向对象编程的一些复杂性和问题。下面是 Go 语言这样设计的一些原因及其替代方式:

为什么 Go 没有传统的继承?

  • 简化语言设计Go 的设计哲学是保持语言的简洁和高效。继承是一个强大但复杂的功能,可以导致多种编程问题,如复杂的依赖关系和难以预测的行为。

  • 避免继承带来的问题

    • 脆弱的基类问题:基类的改变可能影响到大量的派生类。
    • 深层继承结构导致的复杂性:随着继承链的增长,理解和维护代码变得更加困难。
    • 多重继承的复杂性:如 C++ 中的多重继承可能导致菱形继承问题,增加了语言和编译器的复杂性。

Go 如何实现多态?

尽管 Go 没有继承,它通过接口来支持多态性。在 Go 中,接口是一组方法签名的集合,任何类型只要实现了这些方法,就被认为实现了该接口。这种方式与继承不同,更加灵活和简洁:

  • 接口隐式实现:类型不需要声明它实现了哪个接口,这降低了代码之间的耦合。
  • 组合优于继承Go 通过组合(有时候通过嵌入结构体)来实现代码的复用,这比继承更加直接和清晰。

Embedded Struct 算不算继承?

Embedded struct嵌入结构体在 Go 中被用作实现类似继承的功能,但它更准确地被描述为组合。通过嵌入一个结构体,一个新的结构体可以直接访问嵌入结构体的方法和字段,这提供了一种方式来复用代码:

  • 不是真正的继承:虽然看起来类似,嵌入结构体并不提供传统意义上的多态。
  • 代码复用和扩展:它允许一种灵活的方式来扩展功能,而无需继承的复杂性。

传统继承的问题

  • 过度耦合:子类和父类之间的关系过于紧密,改动父类可能会影响所有子类。
  • 隐藏的复杂性:继承可以导致代码的行为不透明,增加理解和调试的难度。
  • 难以正确使用:正确地设计和维护一个继承体系需要大量的设计经验和技术洞察力。

Go 的设计选择鼓励开发者采用更简单、更易于理解和维护的编程范式。通过接口和组合Go 提供了一种强大的工具集来建构灵活且可维护的代码结构,避免了许多传统面向对象编程中常见的陷阱。

Go 中 channel 跟 Java 中 BlockingQueue 又有啥区别 ?

Go 的 channel 和 Java 的 BlockingQueue 都是用于不同线程或协程间的通信机制,但它们的设计哲学和使用场景有所不同。这两种机制都用于解决并发编程中的同步问题,但具体的实现和适用的场景有差异。

Channel 与 BlockingQueue 的区别

  1. 设计哲学:

    • Go 的 ChannelChannel 是 Go 语言中的一等公民用于在协程goroutines之间进行通信。它遵循“通过通信来共享内存而不是通过共享内存来通信”的哲学。
    • Java 的 BlockingQueue是 Java 并发包中的一部分,主要用于线程间的通信,尤其在生产者-消费者模型中。它依赖于共享内存和锁来实现线程安全。
  2. 功能实现:

    • Channel 支持多种模式,如无缓冲、有缓冲通道,可以非常灵活地控制协程间的数据流和同步。
    • BlockingQueue 是一个接口Java 提供了多种实现(如 ArrayBlockingQueue, LinkedBlockingQueue主要通过阻塞操作来实现生产者和消费者之间的同步。
  3. 用途和应用场景:

    • Channel 通常用于协程间的信号传递和数据交换,特别是在需要控制并发操作顺序时。
    • BlockingQueue 通常用于处理较大的数据流或者在多线程环境下缓存数据。

共享内存并发 vs. Channel 并发

共享内存并发

  • 适用场景:适合复杂的数据结构共享,或者当有多个线程需要访问和修改同一数据时。在多核处理器上,这种方式可以有效利用缓存一致性协议。
  • 优点:可以实现细粒度的控制,对于某些高性能计算场景可以更直接地管理内存。
  • 缺点:容易产生竞态条件,编程模型更加复杂,需要精确地控制锁和同步。

Channel 并发

  • 适用场景:适合事件驱动或消息驱动的应用,如网络服务或并行数据处理。在这些场景中,通信模式清晰,各部分之间的解耦更彻底。
  • 优点:简化了并发和同步的管理,代码通常更易于理解和维护。
  • 缺点:在极端的高性能需求下,可能会因为消息传递的开销而不如直接的内存访问高效。

选择建议

  • 如果问题适合通过明确的消息传递进行模块化设计,或者当系统的可维护性和清晰的并发模型比原始性能更重要时,使用 Channel。
  • 如果需要最大限度地控制性能,并且可以管理更复杂的同步策略和竞态风险,使用共享内存可能更合适。

在实际开发中选择合适的并发策略依赖于具体问题、性能需求和团队的熟悉度。对于维护性和开发效率有较高要求的项目Channel 往往是一个更易于管理的选择。

资料

【Golang开发面经】蔚来两轮技术面