知识总结:Go并发编程| 豆包MarsCode AI 刷题

114 阅读10分钟

1. 协程是什么?协程与进程、线程的区别什么?

协程是编写简便、不依赖系统内核、低资源消耗的并发工具。与进程和线程相比协程有以下区别和优势

  • 协程由用户代码控制,具有较小的开销
  • 协程具有更高的执行效率,因为其不需要频繁的上下文切换
  • 协程能够简化并发编程,使代码更易于理解和维护。

概念区分

  • 进程是操作系统中运行的独立程序,每个进程都拥有独立的内存空间和资源。
  • 线程是进程中的一个执行单元,同一进程内的线程共享该进程的内存和资源。
  • 协程(也称作微线程或纤程)是更轻量级的线程,它们是由程序本身调度而不是操作系统调度的。

上下文切换

  • 进程和线程的上下文切换由操作系统内核控制,代价较高,因为需要保存和恢复大量的状态信息。
  • 协程的上下文切换由用户代码控制,代价较低,因为只需要保存和恢复很少的状态信息,这也让协程在高并发场景中表现出色。

资源消耗

  • 创建和销毁进程代价最大,因为它们需要分配和释放大量的系统资源。
  • 创建和销毁线程相对便宜,但仍然涉及一定的系统开销,栈是MB级别。
  • 协程的创建和销毁几乎没有什么系统开销,因为它们不依赖操作系统内核,仅在用户态操作,栈是KB级别。

阻塞与非阻塞

  • 进程和线程在系统调用时可能会发生阻塞,影响效率。
  • 协程可以利用事件循环和非阻塞的IO操作,避免阻塞,进一步优化性能,也使得编写高效的并发代码更加容易。

代码易读性和维护性

  • 使用进程和线程进行并发编程需要使用复杂的同步机制(如锁信号量等)来保证数据一致性,容易导致代码复杂且难维护。
  • 协程通过顺序编写异步执行的代码,简化了并发程序的结构,使代码更直观,更容易理解和维护。

Go语言中的协程(Goroutine)

Go语言通过goroutine和channel来简化并发编程,一个goroutine通常只有几KB的栈空间,并且会根据需求动态增长,使得数以万计的goroutine运行在单个系统上也是现实可行的。另外,Go的调度器能有效地将goroutine映射到操作系统的线程上,进一步提升了并发性能。

2. 聊聊Go并发模型

Go语言的并发模型建立在goroutine和channel之上。其设计理念是共享数据通过通信而不是通过共享来实现的。

  • Goroutines是Go中的轻量级线程,由Go运行时(runtime)管理。与传统线程相比, goroutines的创建和销毁开销很小。程序可以同时运行多个goroutines,它们共享相同的地址空间。
  • Goroutines之间的通信通过channel(通道)实现。通道提供了一种安全、同步的方式,用于在goroutines之间传递数据。使用通道可以避免多个goroutines同时访问共享数据而导致竞态条件的问题。
  • 多路复用: select语句允许在多个通道操作中选择一个执行。这种方式可以有效地处理多个通道的并发操作,避免了阻塞。
  • 互斥锁和条件变量
    • Go提供了sync包,其中包括 Mutex(互斥锁)等同步原语,用于在多个goroutines之间进行互斥访问共享资源。
    • sync包还提供了Cond(条件变量),用于在goroutines之间建立更复杂的同步。
  • 原子操作: Go提供了sync/atomic包,其中包括一系列原子性操作,用于在不使用锁的情况下进行安全的并发操作。 原子操作用户态就可以完成,因此性能比加锁操作更好,但是这些函数必须谨慎地保证正确使用。除了某些特殊的底层应用,使用通道或者sync包的函数/类型实现同步更好。

3. Mutex有几种模式

mutex有两种模式:normalstarvation

  1. 正常模式

在正常模式中,锁的获取是非公平的,即等待锁的Goroutine不保证按照先来先服务(FIFO)的顺序获得锁。新到来的Goroutine有可能在等待时间较长的Goroutine之前获得锁。

  1. 饥饿模式

在饥饿模式中,系统保证等待锁的Goroutine按照一定的公平原则获得锁,避免某些Goroutine长时间无法获取锁的情况。

4. Mutex有几种状态

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

5. Go什么时候发生阻塞?阻塞时调度器会怎么做。

  • 用于原子、互斥量或通道操作导致goroutine阻塞,调度器将把当前阻塞的goroutine从本地运行队列LRQ换出,并重新调度其它goroutine;
  • 由于网络请求和IO导致的阻塞, Go提供了网络轮询器(Netpoller)来处理,后台用epoll等技术实现IO多路复用。

其它回答:

  • channel阻塞:当goroutine读写channel发生阻塞时,会调用gopark函数,该G脱离当前的M和P,调度器将新的G放入当前M。
  • 系统调用:当某个G由于系统调用陷入内核态,该P就会脱离当前M,此时P会更新自己的状态为Psyscall, M与G相互绑定,进行系统调用。结束以后,若该P状态还是Psyscall,则直接关联该M和G,否则使用闲置的处理器处理该G。
  • 系统监控:当某个G在P上运行的时间超过10ms时候,或者P处于Psyscall状态过长等情况就会调用retake函数,触发新的调度。
  • 主动让出:由于是协作式调度,该G会主动让出当前的P(通过GoSched),更新状态为Grunnable,该P会调度队列中的G运行。

6. goroutine什么情况会发生内存泄漏?如何避免。

在Go中内存泄露分为暂时性内存泄露和永久性内存泄露。

暂时性内存泄露

  • 获取长字符串中的一段导致长字符串未释放
  • 获取长slice中的一段导致长slice未释放
  • 在长slice新建slice导致泄漏

string相比切片少了一个容量的cap字段,可以把string当成一个只读的切片类型。获取长string或者切片中的一段内容,由于新生成的对象和老的string或者切片共用一个内存空间,会导致老的string和切片资源暂时得不到释放,造成短暂的内存泄漏。

7. 如何控制Goroutine的生命周期?

  1. 启动:使用go关键字可以去启动一个新的gorountine。
  2. 等待结束:希望主程序等待某个gorountine执行完毕后再继续执行。可以使用sync.WaitGroup来实现等待。
  3. 使用通道(channel)来通知goroutine退出。
  4. 使用context可以实现超时控制、取消和传递参数等功能。

8. Channel如何处理阻塞?

  1. 缓冲通道,在创建通道时指定缓冲区大小,即创建一个缓冲通道。当缓冲区未满时,发送数据不会阻塞。当缓冲区未空时,接收数据不会阻塞。
  2. select语句用于处理多个通道操作,可以用于避免阻塞。
  3. 使用time.After创建一个定时器,可以在超时后执行特定的操作,避免永久阻塞。
  4. select语句中使用default分支,可以在所有通道都阻塞的情况下执行非阻塞的操作。

9. Go语言的context是什么?有什么作用?

Go语言的context是一个标准库中的包,提供了用于上下文管理的功能,包主要用于在多个goroutine之间传递上下文信息,比如:截止时间、取消信号以及携带请求特定值。在并发编程中,它可以帮助我们更好地管理goroutine的生命周期和资源使用。常用在Web服务器API 调用、数据库操作等需要跨多个goroutine协同工作的场景中。

具体来说,context有以下几个主要作用:

  1. 控制并发任务的生命周期:通过在多个goroutine之间共享一个 context.Context,可以实现一处取消,处处响应,如果一个请求超时或客户端取消了请求,那么可以通过context传播取消信号,通知所有相关的goroutine停止工作,从而避免资源泄露。
  2. 传递请求范围内的数据:在处理HTTP请求时,context可以用来传递一些请求相关的数据,比如认证Token、用户信息等。(不建议过多使用)
  3. 设置超时时间:可以通过context来设置某个操作的超时时间,一旦超时,相关的goroutine就会被取消。

10. 聊聊GMP模型

概念

Go语言的GMP模型指的是 (G)Goroutines、P(Processor)、M(Machine) 的模型。这是Go语言实现并发编程时所采用的一种轻量级线程管理调度系统,通过这个模型,Go语言可以实现高效的并发处理。

  1. G(Goroutine): 这是Go中的轻量级线程,类似其他语言中的线程,但更轻便。每个Goroutine分配的初始内存非常小,通常在几kb量级,可以轻松创建成千上万个Goroutinue而不会对系统资源产生巨大的压力。
  2. P(Processor): 代表逻辑处理器,用于调度Goroutines。P提供了Goroutine执行所需的环境,包括运行队列和一些其他状态信息。
  3. M(Machine): 与操作系统线程一一对应,即实际的内核线程。M执行分配给它的Goroutine。

P和M的数量在Go运行时系统中是动态管理的,且可以进行动态调整。

工作流程

  1. 创建Goroutine: 创建一个新的Goroutine时,这个Goroutine就会被放入到一个P的运行队列中等待执行。
  2. 调度Goroutine: P会从它的队列中取出一个Goroutine让一个空闲的M去执行,如果M在运行某个Goroutine时,该Goroutine发生了阻塞,M就会找一个未阻塞的Goroutine来运行,从而最大化CPU的利用率。
  3. 协作式抢占: Go采用协作式调度,即Goroutine放弃对CPU的控制权通常发生在函数调用、进入或退出runtime某些特定代码段时。
  4. 工作窃取: 为了平衡不同的P负载,如果某个P的Gotoutine队列空了,它可以从其他P的队列中“窃取”一些Goroutine来执行。
  5. Goroutine的栈增长: Go中的Goroutine使用的是动态增长的栈空间,因此Goroutine创建时的初始内存很小,让快速创建和销毁大量Goroutine成为可能。

通过上述机制,GMP模型能高效地管理Goroutines和操作系统线程之间的关系,从而实现了高并发且轻量级的协程调度。这也是Go在实现高性能并发程序时的核心优势所在。