简介📃
Go语言(简称Go)是由谷歌公司开发的一种静态强类型、编译型、并发型的编程语言。Go语言的一个显著特点就是对并发编程的良好支持,而Goroutine则是实现这一特性的重要基础。Goroutine可以看作是Go语言中的轻量级线程,它可以帮助开发者轻松实现并发任务,从而更高效地利用多核处理器的能力。
关于计算机中的并发相关知识基础同学们可以参考我的上一期文章 全栈杂谈第一期:什么是计算机中的并发
Goroutine的概念💡
什么是Goroutine?
在Go语言中 是通过 '协程' 来**实现并发**, Goroutine 是 Go 语言特有的名词, 区别于进程 Process, 线程Thread, 协程 Coroutine, 因为 Go语言的作者们觉得是有所区别的,所有专门创造做 Goroutine.
Goroutine 是与其他函数或方法同时运行的函数或方法。 Goroutine 可以被认为是 轻量级的线程 ,于线程相比 创建 Goroutine 的成本很小,他就是一段代码,一个函数入口 以及在堆上分配的一个堆栈(初始大小为 4k, 会随着程序的执行 自动增长删除)。 因此它非常廉价。GO应用程序可以并发运行着数千个 Goroutine.
Goroutine的启动非常简单,只需要在函数调用前加上 go
关键字即可。这种简便的语法使得并发编程在Go中变得异常简单。
封装main函数的Goroutine 称为 主Goroutine,
主Goroutine 所做的事情并不是执行main函数这么简单,它首先要做的是:假设每一个Goroutine 所能申请的栈控件的最大尺寸,在32位计算机系统中 次尺寸为 256MB,而64位计算机中的尺寸为1GB, **如果有某个 goroutine 的栈空间尺寸大于这个限制,那么运行时系统就会引发一个 栈溢出(stack overflow)**的运行时错误,随后 这个Go 程序也会终止。
此后 主goroutine 会进行一系列的初始化工作, 涉及的工作内容大致如下:
- 创建一个特殊的 defer语句,用于在主 goroutine 退出时要做的必要处理。应为主 goroutine 也可能非正常的结束
- 创建专用于在后台清扫垃圾内存的 gorontine ,并设置 GC 可用的标识
- 执行main 包中的 init 函数
- 执行main 函数,执行main 函数之后,它还会检查主 goroutine 是否引发了 运行时错误,并进行必要的处理。最后主 gorontine 才会结束自己以及当前运行的进程
Goroutine与线程的区别
与操作系统级别的线程相比,Goroutine具有以下几个显著的区别:
- 轻量级:Goroutine在启动时只需要很少的内存(大约几KB),而且随着需要可以动态增长。相比之下,操作系统线程通常需要占用几MB的内存。
- 调度管理:Goroutine的调度是由Go语言的运行时系统完成的,而不依赖于操作系统的线程调度机制。Go语言的调度器基于协作式调度和抢占式调度结合的方式,能够在不增加复杂性的前提下实现高效的任务切换。
- 共享内存:Goroutine间共享内存非常方便,这得益于它们运行在同一进程的地址空间内。为了避免共享内存引发的数据竞争问题,Go语言鼓励通过消息传递(Channel)的方式在Goroutine之间进行通信。
Goroutine的生命周期
Goroutine的生命周期可以分为以下几个阶段:
- 创建 :通过
go
关键字启动Goroutine时,Go运行时会为其分配栈空间并创建相应的数据结构。 - 执行:Goroutine开始执行其对应的函数,Go运行时调度器负责在多个Goroutine之间进行任务切换。
- 阻塞:Goroutine在遇到I/O操作、Channel通信、锁等情况时可能会被阻塞,此时它会让出CPU,等待条件满足后继续执行。
- 结束:Goroutine执行完毕或遇到不可恢复的错误时会终止,Go运行时系统回收其栈空间和相关资源。
Goroutine的工作原理🔍️
Go调度器(GMP模型)
Go的调度器基于GMP模型,GMP分别代表Goroutine(G)、线程(M)和调度器(P),它们的关系如下:
- Goroutine(G):Goroutine是Go语言中的任务单元,每个Goroutine都有自己的栈和上下文信息。
- 线程(M):M是操作系统中的内核线程,每个M都可以执行一个或多个Goroutine。
- 调度器(P):P表示逻辑处理器,它管理着与之绑定的一组Goroutine队列。P的数量通常等于可用的CPU核心数量,P负责将Goroutine分配给M执行。
在GMP模型中,Goroutine通过P被分配给M执行。P维护着一个本地的Goroutine队列,当本地队列中的Goroutine不足时,P会尝试从其他P的队列中窃取任务,或从全局队列中获取新的Goroutine。M负责实际执行Goroutine,P则负责Goroutine的调度。
Goroutine的栈管理
在Go语言中,Goroutine是实现并发的核心机制之一。每个Goroutine在创建时都会被分配一个栈,这个栈与操作系统的线程栈不同,它具有动态伸缩的特性。这种设计使得Goroutine在处理并发任务时能够更加高效和灵活。
初始栈大小
Goroutine的栈初始大小非常小,通常只有2KB。这个设计是为了减少内存的初始占用,使得系统可以创建大量的Goroutine而不会对内存造成过大压力。这种小栈的设计也符合Go语言的设计理念,即"以空间换时间",通过牺牲一定的内存来提高程序的并发性能。
动态扩展
当Goroutine执行过程中需要更多的栈空间时,Go运行时会自动扩展栈的大小。这种扩展是按需进行的,每次扩展的大小通常是以一定的增量进行,这样可以避免频繁的内存分配和释放,提高内存的使用效率。
动态缩减
与动态扩展相对应,当Goroutine不再需要那么多栈空间时,Go运行时也会尝试缩减栈的大小。这种缩减操作可以减少内存的浪费,使得内存资源可以被更有效地利用。
栈管理的优势
- 低内存开销:由于初始栈大小小,可以创建大量的Goroutine而不会占用过多的内存。
- 灵活性:动态伸缩的栈使得Goroutine可以根据实际需要调整内存使用,适应不同的并发场景。
- 高效性:Go运行时的栈管理机制减少了内存分配和释放的开销,提高了程序的运行效率。
栈管理的限制
尽管Go的栈管理机制非常高效,但它也有一些限制。例如,栈的最大大小是有限的,通常由操作系统和Go运行时的配置决定。如果Goroutine的栈增长超过了这个限制,程序可能会因为栈溢出而崩溃。
总的来说,Go语言的Goroutine栈管理机制是一个既高效又灵活的设计,它使得Go语言在并发编程方面具有独特的优势。通过动态地管理栈的大小,Go能够有效地处理大量的并发任务,同时保持较低的内存占用。
Goroutine的基本操作🪡
Goroutine的创建与销毁
Goroutine的创建非常简单,只需要在函数调用前加上 go
关键字。以下是一个简单的示例:
go
package main
import (
"fmt"
"time"
)
func sayHello() {
fmt.Println("Hello, Goroutine!")
}
func main() {
go sayHello()
time.Sleep(time.Second)
}
在这个例子中, sayHello
函数被作为一个Goroutine启动。在主函数结束前,使用 time.Sleep
等待一秒钟,以保证 sayHello
函数有机会执行。Goroutine的销毁是由Go的垃圾回收器自动处理的,当Goroutine执行完毕且没有任何引用时,垃圾回收器会回收其资源。
Goroutine的使用场景🖼️
I/O密集型任务
在处理I/O密集型任务时,如文件读写、网络请求等,Goroutine可以极大地提高程序的并发能力。由于Goroutine在遇到I/O阻塞时会自动让出CPU,其他Goroutine可以利用这些空闲时间执行,从而提升系统的整体吞吐量。
例如,在Web服务器中,处理每个客户端请求时都可以启动一个Goroutine来处理。由于网络I/O通常会有大量的阻塞操作,使用Goroutine可以避免浪费CPU资源。
计算密集型任务
尽管Goroutine在计算密集型任务中的优势不如I/O密集型任务那么明显,但通过合理地将任务分解为多个并行的子任务,Goroutine仍然可以显著提升程序的执行效率。Go语言的 runtime.GOMAXPROCS
函数允许开发者设置并发执行的逻辑CPU数量,从而更好地利用多核处理器。
定时任务和后台任务
Goroutine非常适合用来处理定时任务和后台任务。例如,可以通过 time.Ticker
配合Goroutine实现周期性任务,或者使用Goroutine监控系统状态、处理异步事件等。
常见问题与解决方案❓️
数据竞争
在多个Goroutine并发访问共享数据时,可能会引发数据竞争问题。为了避免数据竞争,可以通过Channel传递数据,或者使用 sync.Mutex
进行加锁。
Goroutine泄漏
如果Goroutine一直处于阻塞状态,或者等待的资源永远无法到达,可能会导致Goroutine泄漏。为了避免Goroutine泄漏,开发者需要仔细设计程序的并发逻辑,确保所有Goroutine能够正常退出。
高效利用CPU资源
Go语言的调度器会自动根据系统的负载调节Goroutine的执行,但在某些情况下,可以通过合理配置 GOMAXPROCS
等参数优化性能,充分利用多核处理器的计算能力。