在Java或C++中实现并发编程时,通常需要自己维护一个线程池,并需要包装一个又一个的任务去调度线程执行任务同时还需要维护上下文切换,这一切通常会耗费开发人员大量心智。能不能有一种机制,开发人员只需要定义任务,让系统去将任务分配到CPU上实现并发执行呢?
Go语言的goroutine
就是这种机制,goroutine
的概念类似于线程,不同之处在于goroutine
由Go程序运行时调度和管理。Go程序会智能地将goroutine
中的任务合理地分配给每个CPU。
Go语言之所以称为现代化编程语言,是因为它在语言层面上内置了调度和上下文切换的机制。因此在Go并发编程中开发人员无需自己编写进程、线程、协程,只需要编写goroutine
让任务并发执行。
并发 & 并行
简单来说,并发是在同一时间处理多件事情,并行是在同一时间做多件事情。并发的目的在于把当个CPU的利用率使用到最高,而并行则需要多核CPU的支持。
- 并发:逻辑上具备指同一时间段内同时执行多个任务的能力
- 并行:物理上同一时刻执行多个并发任务
Go语言最高特色之一是支持高并发编程模型,以goroutine
作为基本的执行单元。
goroutine
Go语言使用go
关键字开启goroutinue
来支持并发编程,goroutine
是轻量级线程,goroutine
的调度是由Go运行时进行管理的。使用go
语句开启一个全新的运行期线程,即goroutine
。会以一个不同的且全新的goroutine
来执行一个函数。同一个函数中所有goroutine
将共享同一个地址空间。
例如:创建并发执⾏单元,Go调度器会将其安排到合适的系统线程上去执行。
go funcName(argumentList)
注意函数前添加go
关键字是创建并发任务,而非执行并发操作。新建的并行任务单元会被放置到系统队列中,等待调度器安排合适的系统线程去获取执行权。
Go程序中使用go
关键字为一个函数创建一个goroutine
,一个函数可以创建出多个goroutine
,而一个goroutine
必定对应一个函数。启动goroutine
只需在调用函数(普通函数或匿名函数)前添加go
关键字。
使用匿名函数创建goroutine
go
关键字可以为匿名函数或闭包启动goroutine
go func(argumentList) {
//function body
}(parameters)
例如:并行执行定时打印计数
package main
import (
"fmt"
"time"
)
func main() {
//并发执行计数器
go func() {
var count int
for {
count++
fmt.Println("tick", count)
time.Sleep(time.Second)
}
}()
//接收命令行输入
var input string
fmt.Scanln(&input)
}
并行
例如:计数器每秒执行1次的同时等待用户输入
package main
import (
"fmt"
"time"
)
//每隔1秒打印依次计数器
func counter() {
var count int
//无限循环
for {
count++
fmt.Println("tick", count)
time.Sleep(time.Second) //延迟1秒
}
}
func main() {
//并发执行计数器
go counter()
//接收命令行输入
var input string
fmt.Scanln(&input)
}
Go程序启动时运行时runtime
会默认为main()
主函数创建一个goroutine
,在main()
主函数的goroutine
中执行go counter()
时,counter()
计数器函数的goroutine
被创建。counter()
函数在自己的goroutine
中执行。同时main()
主函数依然持续运行。两个goroutine
通过Go程序的调度机制同时在运行。
串行
在Windows和Linux出现之前的古老年代,程序员在开发时并没有并发的概念,因为命令式程序设计语言是以串行为基础的,程序会顺序执行的每一条指令,整个程序只有一个执行上下文,即一个调用栈和一个堆。
并发意味着程序在运行时具有多个执行上下文,对应着多个调用栈。由于每个进程在运行时都具有自己的调用栈和堆,因此会拥有一个完整的上下文。而操作系统在调度进程时会保存被调度进程的上下文环境,等待进程获得时间片后,再恢复该进程的上下文到系统中。
例如:默认Go程序是安装串行方式从上到下依次执行的
$ cd project
$ mkdir base && cd base
$ go mod init base
$ vim main.go
package main
import "fmt"
func test() {
fmt.Println("hello test")
}
func main() {
test()
fmt.Println("hello main")
}
$ go run main.go
hell test
hello main
main goroutine & work goroutine
Go程序启动时会为main()
函数创建一个默认的goroutine
即main goroutine
,当main()
函数返回时main goroutine
结束,所有在main()
函数中启动的goroutine
也会一起结束。
package main
import (
"fmt"
)
func test() {
fmt.Println("hell test")
}
func main() {
go test()
fmt.Println("hello main")
}
$ go run main.go
hello main
因为goroutine
和线程一样,主函数main()
的goroutine
并不会等待其它goroutine
结束。如果main()
函数中的goroutine
结束,其下的所有goroutine
都将结束。
需要注意的是,函数调用前添加go
关键字表示本次调用会在一个全新的goroutine
中并发执行,当被调用的函数return
返回时,当前的work goroutine
也会自动结束。如果函数存在返回值,则返回值会被丢弃。
goroutine & sleep
若将test()
替换为go test()
则此时会启动另外一个goroutine
去执行test()
函数,因此打印时只会出现hello main
,而不会出现hello test
。若想要打印出hello test
简单粗暴的方式是使用time.Sleep
。
package main
import (
"fmt"
"time"
)
func test() {
fmt.Println("hell test")
}
func main() {
go test()
fmt.Println("hello main")
time.Sleep(time.Second)
}
$ go run main.go
hell test
hello main
main goroutine
退出后,其它的work goroutine
也会自动退出。
coroutine VS thread
Go语言的并发是通过goroutine
来实现的,goroutine
类似于线程,属于用户态的线程。Go语言是一个原生支持用户态线程goroutine
的语言,可根据需要创建多个goroutine
并发工作。与线程不同的是,goroutine
是由Go语言的运行时调度完成的,而线程则是由操作系统调度完成的。
进程、线程、协程区别
- 进程(process)
进程拥有自己独立的堆和栈,即不共享堆也不共享栈,进程由操作系统调度。 - 线程(thread)
线程拥有自己独立的栈和共享的堆,即共享堆不共享栈,线程由操作系统调度。 - 协程(coroutine)
协程和线程一样共享堆但不共享栈,协程是由开发人员在编码中进行手工调度。
简单地将goroutine
归纳为协程并不合适,goroutine
会在运行时创建多个线程来执行并发任务,goroutine
并发执行单元可被调度到其它线程中并行执行,因此更像是多线程和协程的综合体,能最大限度地提升执行效率,以发挥多核处理能力。
goroutine VS thread
goroutine
是建立在线程之上的轻量级的抽象,goroutine
允许以非常低的代价在同一个地址空间中并行第执行多个函数或方法。相比于线程,goroutine
的创建和销毁的代价要小很多,而且它的调度是独立于线程的。
goroutine
与线程之间的区别
首先goroutine
并不会比线程运行得更快,goroutine
只会增加更多的并发性。因为当一个goroutine
被阻塞(比如等待I/O)时,Go的Scheduler会调度其它可以执行的goroutine
继续运行。
相比于线程,goroutine
优点在于
- 内存消耗更少
goroutine
所需的内存通常只有20KB,而线程则需要1MB
,之间500倍差距。
- 创建于销毁的开销更小
线程创建时需要向操作系统申请资源,销毁时必须将资源归还,因此创建和销毁时的开销更大。而goroutine
的创建和销毁是由Go语言在运行时自己管理的,因此更低。
![](https://img-blog.csdnimg.cn/img_convert/cbbd88d15bc375751866b422ee071c35.png)
- 切换开销更小
Go实现高并发的主要原因由于切换开销更小,这也是与线程最主要的区别。线程的调度方式是抢占式的,如果一个线程的执行时间超过了分配给它的时间片,就会被其它可执行的线程抢占。在线程切换的过程中需要保存和恢复所有及寄存器信息,比如16个通用寄存器、PC(Program Counter)程序计数器、SP(Stack Pointer)堆栈指针、段寄存器等等。goroutine
的调度是协同式的,不会直接与操作系统内核打交道。当goroutine
进行切换时,只有少量的寄存器需要保存和恢复,因此切换效率更高。
goroutine
是Go并行设计的核心,说到底其实是协程,比线程更小,十几个goroutine
体现在底层可能就是五六个线程。Go语言内部实现了goroutine
之间的内存共享。执行goroutine
仅需极少的栈内存(大约4~5KB),会根据相应的数据来伸缩。因此,可同时运行成千上万的并发任务。