Go 协程

Go 协程是什么?

我们知道,线程是cpu能够调度的最小单位,在单线程下实现并发我们称之为协程。

Go 协程是与其他函数或方法一起并发运行的函数或方法。Go 协程可以看作是轻量级线程。与线程相比,创建一个 Go 协程的成本很小。因此在 Go 应用中,常常会看到有数以千计的 Go 协程并发地运行。


 

Go 协程相比于线程的优势

  • 相比线程而言,Go 协程的成本极低。堆栈大小只有若干 kb,并且可以根据应用的需求进行增减。而线程必须指定堆栈的大小,其堆栈是固定不变的。
  • Go 协程会复用(Multiplex)数量更少的 OS 线程。即使程序有数以千计的 Go 协程,也可能只有一个线程。如果该线程中的某一 Go 协程发生了阻塞(比如说等待用户输入),那么系统会再创建一个 OS 线程,并把其余 Go 协程都移动到这个新的 OS 线程。所有这一切都在运行时进行,作为程序员,我们没有直接面临这些复杂的细节,而是有一个简洁的 API 来处理并发。
  • Go 协程使用信道(Channel)来进行通信。信道用于防止多个协程访问共享内存时发生竞态条件(Race Condition)。信道可以看作是 Go 协程之间通信的管道。我们会在下一教程详细讨论信道。Go语言推崇用信道通信,而不推崇用共享变量通信(涉及到锁,死锁现象)。

 

如何启动一个 Go 协程?

调用函数或者方法时,在前面加上关键字 go,可以让一个新的 Go 协程并发地运行。

让我们创建一个 Go 协程吧。

package main

import (
    "fmt"
)

func hello() {
    fmt.Println("Hello world goroutine")
}
func main() {
    go hello()
    fmt.Println("main function")
}

在第 11 行,go hello() 启动了一个新的 Go 协程。现在 hello() 函数与 main() 函数会并发地执行。主函数会运行在一个特有的 Go 协程上,它称为 Go 主协程(Main Goroutine)。

运行一下程序,你会很惊讶!

该程序只会输出文本 main function。我们启动的 Go 协程究竟出现了什么问题?要理解这一切,我们需要理解两个 Go 协程的主要性质。

  • 启动一个新的协程时,协程的调用会立即返回。与函数不同,程序控制不会去等待 Go 协程执行完毕。在调用 Go 协程之后,程序控制会立即返回到代码的下一行,忽略该协程的任何返回值。
  • 如果希望运行其他 Go 协程,Go 主协程必须继续运行着。如果 Go 主协程终止,则程序终止,于是其他 Go 协程也不会继续运行。

现在你应该能够理解,为何我们的 Go 协程没有运行了吧。在第 11 行调用了 go hello() 之后,程序控制没有等待 hello 协程结束,立即返回到了代码下一行,打印 main function。接着由于没有其他可执行的代码,Go 主协程终止,于是 hello 协程就没有机会运行了。

我们现在修复这个问题。

package main

import (  
    "fmt"
    "time"
)

func hello() {  
    fmt.Println("Hello world goroutine")
}
func main() {  
    go hello()
    time.Sleep(1 * time.Second)
    fmt.Println("main function")
}

在上面程序的第 13 行,我们调用了 time 包里的函数 [Sleep],该函数会休眠执行它的 Go 协程。在这里,我们使 Go 主协程休眠了 1 秒。因此在主协程终止之前,调用 go hello() 就有足够的时间来执行了。该程序首先打印 Hello world goroutine,等待 1 秒钟之后,接着打印 main function

在 Go 主协程中使用休眠,以便等待其他协程执行完毕,这种方法只是用于理解 Go 协程如何工作的技巧。信道可用于在其他协程结束执行之前,阻塞 Go 主协程。我们会在下一教程中讨论信道。

总计:Go语言中,主线程不会等待goroutine执行完成,要等待它结束需要自己处理; python中默认主线程会等待子线程执行完成,因此会先打印hello()。

 

启动多个 Go 协程

为了更好地理解 Go 协程,我们再编写一个程序,启动多个 Go 协程。

package main

import (  
    "fmt"
    "time"
)

func numbers() {  
    for i := 1; i <= 5; i++ {
        time.Sleep(250 * time.Millisecond)
        fmt.Printf("%d ", i)
    }
}
func alphabets() {  
    for i := 'a'; i <= 'e'; i++ {
        time.Sleep(400 * time.Millisecond)
        fmt.Printf("%c ", i)
    }
}
func main() {  
    go numbers()
    go alphabets()
    time.Sleep(3000 * time.Millisecond)
    fmt.Println("main terminated")
}

在上面程序中的第 21 行和第 22 行,启动了两个 Go 协程。现在,这两个协程并发地运行。numbers 协程首先休眠 250 微秒,接着打印 1,然后再次休眠,打印 2,依此类推,一直到打印 5 结束。alphabete 协程同样打印从 a 到 e 的字母,并且每次有 400 微秒的休眠时间。 Go 主协程启动了 numbers 和 alphabete 两个 Go 协程,休眠了 3000 微秒后终止程序。

该程序会输出:1 a 2 3 b 4 c 5 d e main terminated

 

Go语言的GMP模型

Go协程调度原理如上图,称为GMP模型:

G:启动的goroutine, 启动后放在全局队列里,经过调度器,会把所有的goroutine放到 P 的本地队列中,有几个P,就有几个本地队列,比较均匀的把goroutine分配到本地队列。
M:实际上是用户线程,我们可以把它当成操作系统真正的线程。
P:Processor,现在版本默认情况是cpu核数,可以把它当做cpu核数(一般也设置为cpu核数,比如4核cpu,操作系统启4条线程,对应4个M用户线程,4个M对应4个P),P用来跟M对接,映射到真正的线程,去执行P队列中的goroutine(执行加了go关键字的函数),一旦某个goroutine遇到阻塞,它就回到队列末尾,遇到IO会自动切换goroutine。


补充:

所有语言开线程,都是程序开的线程,再通知操作系统开线程,而不是直接在操作系统开出线程。因此区分了:用户线程,操作系统线程。
python中,先开出用户线程,用户线程跟操作系统线程是一对一的对应关系,程序开一个线程操作系统就开一个线程,用户线程是运行在操作系统线程之上的。
某些语言,用户线程和操作系统线程是n:1的关系。
Go语言,用户线程和操作系统线程是 m:n的关系,Go语言中为了速度快,它开的m用户线程,可能比操作系统真正的线程n要多。

 

通过一个小例子,印证一下Go协程模式对于cpu的占用,体现出其代码运行的高性能:

package main

import (
    "fmt"
    "runtime"
    "time"
)

func main() {
    runtime.GOMAXPROCS(1)  //设置P的大小为1(相当于只用1核cpu),不管开多少个goroutine只有1个P,只能映射到一个M中执行
    fmt.Println("主线程开始执行") 
    for i := 0; i < 10; i++ {   //启一个循环,循环里面开了10个goroutine执行
        go func() { 
            for {  //goroutine 执行一段死循环代码
                fmt.Println("我是死循环")
            }
        }()
    }
    time.Sleep(10 * time.Second)  //主线程给goroutine  10秒钟的时间来执行
    fmt.Println("主线程结束执行")
}

上面的程序中,我们设置了P的大小为1,相当于只利用1个cpu来执行这段并发代码。因为死循环会占满cpu,如果我们的机器cpu是4核,通过任务管理器查看,理论上我们这段程序cpu的利用率应该为25%;接下来如果我们注释掉runtime.GOMAXPROCS(1),P默认使用cpu核数,即4个cpu同时执行这段代码,通过任务管理器查看,理论上这段程序的cpu的利用率应该为100%。

posted @ 2022-11-23 16:29  不会钓鱼的猫  阅读(302)  评论(0编辑  收藏  举报