共计 4772 个字符,预计需要花费 12 分钟才能阅读完成。
Go 语言最大的特色就是从语言层面支持并发(Goroutine),Goroutine 是 Go 中最基本的执行单元。事实上每一个 Go 程序至少有一个 Goroutine:主 Goroutine。当程序启动时,它会自动创建。
为了更好理解 Goroutine,现讲一下线程和协程的概念
线程(Thread):有时被称为轻量级进程(Lightweight Process,LWP),是程序执行流的最小单元。一个标准的线程由线程 ID,当前指令指针(PC),寄存器集合和堆栈组成。另外,线程是进程中的一个实体,是被系统独立调度和分派的基本单位,线程自己不拥有系统资源,只拥有一点儿在运行中必不可少的资源,但它可与同属一个进程的其它线程共享进程所拥有的全部资源。
线程拥有自己独立的栈和共享的堆,共享堆,不共享栈,线程的切换一般也由操作系统调度。
协程(coroutine):又称微线程与子例程(或者称为函数)一样,协程(coroutine)也是一种程序组件。相对子例程而言,协程更为一般和灵活,但在实践中使用没有子例程那样广泛。
和线程类似,共享堆,不共享栈,协程的切换一般由程序员在代码中显式控制。它避免了上下文切换的额外耗费,兼顾了多线程的优点,简化了高并发程序的复杂。
Goroutine 和其他语言的协程(coroutine)在使用方式上类似,但从字面意义上来看不同(一个是 Goroutine,一个是 coroutine),再就是协程是一种协作任务控制机制,在最简单的意义上,协程不是并发的,而 Goroutine 支持并发的。因此 Goroutine 可以理解为一种 Go 语言的协程。同时它可以运行在一个或多个线程上。
先给个简单实例
func loop() {
for i := 0; i < ; i++ {
fmt.Printf(“%d “, i)
}
}
func main() {
go loop() // 启动一个 goroutine
loop()
}
GO 并发的实现原理
一、Go 并发模型
Go 实现了两种并发形式。第一种是大家普遍认知的:多线程共享内存。其实就是 Java 或者 C ++ 等语言中的多线程开发。另外一种是 Go 语言特有的,也是 Go 语言推荐的:CSP(communicating sequential processes)并发模型。
CSP 并发模型是在 1970 年左右提出的概念,属于比较新的概念,不同于传统的多线程通过共享内存来通信,CSP 讲究的是“以通信的方式来共享内存”。
请记住下面这句话:DO NOT COMMUNICATE BY SHARING MEMORY; INSTEAD, SHARE MEMORY BY COMMUNICATING.“不要以共享内存的方式来通信,相反,要通过通信来共享内存。”
普通的线程并发模型,就是像 Java、C++、或者 Python,他们线程间通信都是通过共享内存的方式来进行的。非常典型的方式就是,在访问共享数据(例如数组、Map、或者某个结构体或对象)的时候,通过锁来访问,因此,在很多时候,衍生出一种方便操作的数据结构,叫做“线程安全的数据结构”。例如 Java 提供的包”java.util.concurrent”中的数据结构。Go 中也实现了传统的线程并发模型。
Go 的 CSP 并发模型,是通过 goroutine 和 channel 来实现的。
goroutine 是 Go 语言中并发的执行单位。有点抽象,其实就是和传统概念上的”线程“类似,可以理解为”线程“。
channel 是 Go 语言中各个并发结构体 (goroutine) 之前的通信机制。通俗的讲,就是各个 goroutine 之间通信的”管道“,有点类似于 Linux 中的管道。
生成一个 goroutine 的方式非常的简单:Go 一下,就生成了。
go f();
通信机制 channel 也很方便,传数据用 channel <- data,取数据用 <-channel。
在通信过程中,传数据 channel <- data 和取数据 <-channel 必然会成对出现,因为这边传,那边取,两个 goroutine 之间才会实现通信。
而且不管传还是取,必阻塞,直到另外的 goroutine 传或者取为止。
示例如下:
package main
import “fmt”
func main() {
messages := make(chan string)
go func() { messages <- “ping”}()
msg := <-messages
fmt.Println(msg)
}
注意 main()本身也是运行了一个 goroutine。
messages:= make(chan int) 这样就声明了一个阻塞式的无缓冲的通道
chan 是关键字 代表我要创建一个通道
GO 并发模型的实现原理
我们先从线程讲起,无论语言层面何种并发模型,到了操作系统层面,一定是以线程的形态存在的。而操作系统根据资源访问权限的不同,体系架构可分为用户空间和内核空间;内核空间主要操作访问 CPU 资源、I/ O 资源、内存资源等硬件资源,为上层应用程序提供最基本的基础资源,用户空间呢就是上层应用程序的固定活动空间,用户空间不可以直接访问资源,必须通过“系统调用”、“库函数”或“Shell 脚本”来调用内核空间提供的资源。
我们现在的计算机语言,可以狭义的认为是一种“软件”,它们中所谓的“线程”,往往是用户态的线程,和操作系统本身内核态的线程(简称 KSE),还是有区别的。
线程模型的实现,可以分为以下几种方式:
用户级线程模型
如图所示,多个用户态的线程对应着一个内核线程,程序线程的创建、终止、切换或者同步等线程工作必须自身来完成。它可以做快速的上下文切换。缺点是不能有效利用多核 CPU。
内核级线程模型
这种模型直接调用操作系统的内核线程,所有线程的创建、终止、切换、同步等操作,都由内核来完成。一个用户态的线程对应一个系统线程,它可以利用多核机制,但上下文切换需要消耗额外的资源。C++ 就是这种。
两级线程模型
这种模型是介于用户级线程模型和内核级线程模型之间的一种线程模型。这种模型的实现非常复杂,和内核级线程模型类似,一个进程中可以对应多个内核级线程,但是进程中的线程不和内核线程一一对应;这种线程模型会先创建多个内核级线程,然后用自身的用户级线程去对应创建的多个内核级线程,自身的用户级线程需要本身程序去调度,内核级的线程交给操作系统内核去调度。
M 个用户线程对应 N 个系统线程,缺点增加了调度器的实现难度。
Go 语言的线程模型就是一种特殊的两级线程模型(GPM 调度模型)。
Go 线程实现模型 MPG
M 指的是 Machine,一个 M 直接关联了一个内核线程。由操作系统管理。P 指的是”processor”,代表了 M 所需的上下文环境,也是处理用户级代码逻辑的处理器。它负责衔接 M 和 G 的调度上下文,将等待执行的 G 与 M 对接。G 指的是 Goroutine,其实本质上也是一种轻量级的线程。包括了调用栈,重要的调度信息,例如 channel 等。
P 的数量由环境变量中的 GOMAXPROCS 决定,通常来说它是和核心数对应,例如在 4Core 的服务器上回启动 4 个线程。G 会有很多个,每个 P 会将 Goroutine 从一个就绪的队列中做 Pop 操作,为了减小锁的竞争,通常情况下每个 P 会负责一个队列。
三者关系如下图所示:
以上这个图讲的是两个线程 (内核线程) 的情况。一个 M 会对应一个内核线程,一个 M 也会连接一个上下文 P,一个上下文 P 相当于一个“处理器”,一个上下文连接一个或者多个 Goroutine。为了运行 goroutine,线程必须保存上下文。
上下文 P(Processor)的数量在启动时设置为 GOMAXPROCS 环境变量的值或通过运行时函数 GOMAXPROCS()。通常情况下,在程序执行期间不会更改。上下文数量固定意味着只有固定数量的线程在任何时候运行 Go 代码。我们可以使用它来调整 Go 进程到个人计算机的调用,例如 4 核 PC 在 4 个线程上运行 Go 代码。
图中 P 正在执行的 Goroutine 为蓝色的;处于待执行状态的 Goroutine 为灰色的,灰色的 Goroutine 形成了一个队列 runqueues。
Go 语言里,启动一个 goroutine 很容易:go function 就行,所以每有一个 go 语句被执行,runqueue 队列就在其末尾加入一个 goroutine,一旦上下文运行 goroutine 直到调度点,它会从其 runqueue 中弹出 goroutine,设置堆栈和指令指针并开始运行 goroutine。
抛弃 P(Processor)
你可能会想,为什么一定需要一个上下文,我们能不能直接除去上下文,让 Goroutine 的 runqueues 挂到 M 上呢?答案是不行,需要上下文的目的,是让我们可以直接放开其他线程,当遇到内核线程阻塞的时候。
一个很简单的例子就是系统调用 sysall,一个线程肯定不能同时执行代码和系统调用被阻塞,这个时候,此线程 M 需要放弃当前的上下文环境 P,以便可以让其他的 Goroutine 被调度执行。
如上图左图所示,M0 中的 G0 执行了 syscall,然后就创建了一个 M1(也有可能来自线程缓存),(转向右图)然后 M0 丢弃了 P,等待 syscall 的返回值,M1 接受了 P,将·继续执行 Goroutine 队列中的其他 Goroutine。
当系统调用 syscall 结束后,M0 会“偷”一个上下文,如果不成功,M0 就把它的 Gouroutine G0 放到一个全局的 runqueue 中,将自己置于线程缓存中并进入休眠状态。全局 runqueue 是各个 P 在运行完自己的本地的 Goroutine runqueue 后用来拉取新 goroutine 的地方。P 也会周期性的检查这个全局 runqueue 上的 goroutine,否则,全局 runqueue 上的 goroutines 可能得不到执行而饿死。
均衡的分配工作
按照以上的说法,上下文 P 会定期的检查全局的 goroutine 队列中的 goroutine,以便自己在消费掉自身 Goroutine 队列的时候有事可做。假如全局 goroutine 队列中的 goroutine 也没了呢?就从其他运行的中的 P 的 runqueue 里偷。
每个 P 中的 Goroutine 不同导致他们运行的效率和时间也不同,在一个有很多 P 和 M 的环境中,不能让一个 P 跑完自身的 Goroutine 就没事可做了,因为或许其他的 P 有很长的 goroutine 队列要跑,得需要均衡。该如何解决呢?
Go 的做法倒也直接,从其他 P 中偷一半!
Goroutine 小结
优点:
1、开销小
POSIX 的 thread API 虽然能够提供丰富的 API,例如配置自己的 CPU 亲和性,申请资源等等,线程在得到了很多与进程相同的控制权的同时,开销也非常的大,在 Goroutine 中则不需这些额外的开销,所以一个 Golang 的程序中可以支持 10w 级别的 Goroutine。
每个 goroutine (协程) 默认占用内存远比 Java、C 的线程少(goroutine:2KB,线程:8MB)
2、调度性能好
在 Golang 的程序中,操作系统级别的线程调度,通常不会做出合适的调度决策。例如在 GC 时,内存必须要达到一个一致的状态。在 Goroutine 机制里,Golang 可以控制 Goroutine 的调度,从而在一个合适的时间进行 GC。
在应用层模拟的线程,它避免了上下文切换的额外耗费,兼顾了多线程的优点。简化了高并发程序的复杂度。
缺点:
协程调度机制无法实现公平调度。
参考:
https://medium.com/@trevor4e/…
https://i6448038.github.io/20…
Understanding goroutines versus coroutines
https://gobyexample.com/channels
http://morsmachine.dk/go-sche…
links
目录