首页 > 编程笔记

Go语言协程(goroutine)详解

Go 语言支持轻量级线程,叫做协程(goroutine) 。

Go 语言标准库提供的所有系统调用操作(当然也包括所有同步 I/O 操作),都会出让 CPU 给其他协程(创建协程会自动分配一个合适的 CPU 优先级,不管优先级如何,都会与同级协程竞争 CPU 资源,从外部看来就是出让了部分 CPU 资源)。这让轻量级线程的切换管理不依赖于系统的线程和进程,也不依赖于 CPU 的核心数量,而是交给 Go 语言运行时负责统一调度(当然也允许手动控制)。

那么什么是协程呢?本节学习协程的基本知识。

什么是协程

Go 语言的并发是通过协程来实现的,协程类似于线程,可以根据需要创建成千上万个协程并发工作,但协程是由 Go 程序运行时(runtime)的调度和管理。Go 程序智能地将协程中的任务合理地分配给每个 CPU。

协程是 Go 语言并行设计的核心,它比线程更小,十几个协程可能体现在底层也就是五六个线程,Go 语言内部实现了协程之间的内存共享。执行协程只需极少的栈内存(4~5KB),当然会根据相应的数据伸缩。也正因为如此,程序可同时运行成千上万个并发任务。

和线程相比,协程更易用、更高效、更轻便。

协程的创建

Go 程序从 main 包的 main() 函数开始,在程序启动时,Go 程序就会为 main() 函数创建一个默认的协程。

使用 go 关键字就可以创建协程,将 go 声明放到一个需调用的函数之前,在相同地址空间调用运行这个函数,这样该函数执行时便会作为一个独立的并发线程,这种线程在 Go 语言中被称为协程。

创建协程的语法格式如下:
go 函数名( 参数列表 )

使用 go 关键字创建协程时,被调用函数的返回值会被忽略。

例如,使用 go 关键字,将 running() 函数并发执行,每隔一秒打印一次计数器,而 main 的协程则等待用户输入,两个行为可以同时进行。
package main
import (
    "fmt"
    "time"
)
func running() {
    var times int
    //构建一个无限循环
    for {
        times++
        fmt.Println("tick", times)
        //延时1秒
        time.Sleep(time.Second)
    }
}
func main() {
    //并发执行程序
    go running()
    //接收命令行输入, 不做任何事情
    var input string
    fmt.Scanln(&input)
}
运行结果为:

tick 1
tick 2
tick 3
tick 4
tick 5
...

代码执行后,命令行会不断输出 tick,同时可以使用 fmt.Scanln() 接收用户输入。两个环节可以同时进行。

在以上代码中:
在本例中,Go 程序在启动时,运行时(runtime)会默认为 main() 函数创建一个协程。在 main() 函数的协程中执行到 go running 语句时,归属于 running() 函数的协程被创建,running() 函数开始在自己的协程中执行。此时,main() 函数继续执行,两个协程通过 Go 程序的调度机制同时运作。

另外,还可以使用匿名函数来创建协程。

使用匿名函数创建协程的语法格式如下:
go func( 参数列表 ){
    函数体
}( 调用参数列表 )

例如,在 main() 函数中创建一个匿名函数,并为匿名函数启动协程。匿名函数没有参数。
package main
import (
    "fmt"
    "time"
)
func main() {
    go func() {
        var times int
        for {
            times++
            fmt.Println("tick", times)
            time.Sleep(time.Second)
        }
    }()
    var input string
    fmt.Scanln(&input)
}
在以上代码中:
注意,所有协程在 main() 函数结束时会一同结束。协程虽然类似于线程概念,但是从调度性能上没有线程细致,而细致程度取决于 Go 程序的协程调度器的实现和运行环境。终止协程的最好方法就是自然返回协程对应的函数。

协程间的通信

关键字 go 的引入使得在 Go 语言中并发编程变得简单而便捷,但同时也应该意识到并发编程的原生复杂性,并时刻对并发中容易出现的问题保持警惕。

事实上,不管是什么平台,什么编程语言,不管在哪里,并发都是一个大话题,话题大小通常也直接对应于问题的大小。并发编程的难度在于协调,而协调就要通过交流,从这个角度来看,并发单元间的通信是最大的问题。

通常有两种最常见的并发通信模型:共享数据和消息。

共享数据是指多个并发单元分别保持对同一个数据的引用,实现对该数据的共享。被共享的数据可能有多种形式,如内存数据块、磁盘文件、网络数据等。在实际工程应用中最常见的是内存,也就是常说的共享内存。
package main
import (
   "fmt"
   "runtime"
   "sync"
)
var counter int = 0
func Count (lock *sync.Mutex) {
   lock. Lock()
   counter++
   fmt.Println(counter)
   lock. Unlock()
}
func main () {
   lock := &sync.Mutex{}
   for i:= 0; i<10; i++ {
       go Count(lock )
   }
   for {
       lock.Lock()
       c := counter
       lock .Unlock()
       runtime . Gosched( )
       if c >= 10 {
           break
       }
   }
}
运行结果为:

1
2
3
4
5
6
7
8
9
10

在以上代码中,在 10 个协程中共享了变量 counter。每个协程执行完成后,将 counter 的值加 1。因为 10 个协程是并发执行的,所以还引入了锁,也就是代码中的 lock 量。每次对 n 的操作,都要先将锁锁住,操作完成后,再将锁打开。

在主函数中,使用 for 循环来不断检查 counter 的值(同时需要加锁)。当其值达到 10 时,说明所有协程都执行完毕了,这时主函数返回,程序退出。

在以上代码中实现的功能非常简单,但是却使用了很复杂的代码去编写,Go 语言既然以并发编程作为语言的核心优势,当然不至于将这样的问题用这么复杂的方式来解决。

Go 语言提供的是另一种通信模型,即以消息机制而非共享内存作为通信方式。

消息机制认为每个并发单元是自包含的、独立的个体,并且都有自己的变量,但在不同并发单元之间这些变量不共享。每个并发单元的输入和输出只有一种,那就是消息。这有点类似于进程的概念,每个进程不会被其他进程打扰,它只做好自己的工作就可以了,不同进程间靠消息来通信,它们不会共享内存。

注意:不要通过共享内存来通信,而应该通过通信来共享内存。

Go语言提供的消息通信机制被称为通道(channel),我会专门写一篇文章进行详细地讲解。

推荐阅读

副业交流群 关注微信公众号,加入副业交流群,学习变现经验,交流各种打法。