Go语言协程(goroutine)详解
Go 语言支持轻量级线程,叫做协程(goroutine) 。
Go 语言标准库提供的所有系统调用操作(当然也包括所有同步 I/O 操作),都会出让 CPU 给其他协程(创建协程会自动分配一个合适的 CPU 优先级,不管优先级如何,都会与同级协程竞争 CPU 资源,从外部看来就是出让了部分 CPU 资源)。这让轻量级线程的切换管理不依赖于系统的线程和进程,也不依赖于 CPU 的核心数量,而是交给 Go 语言运行时负责统一调度(当然也允许手动控制)。
那么什么是协程呢?本节学习协程的基本知识。
协程是 Go 语言并行设计的核心,它比线程更小,十几个协程可能体现在底层也就是五六个线程,Go 语言内部实现了协程之间的内存共享。执行协程只需极少的栈内存(4~5KB),当然会根据相应的数据伸缩。也正因为如此,程序可同时运行成千上万个并发任务。
和线程相比,协程更易用、更高效、更轻便。
使用 go 关键字就可以创建协程,将 go 声明放到一个需调用的函数之前,在相同地址空间调用运行这个函数,这样该函数执行时便会作为一个独立的并发线程,这种线程在 Go 语言中被称为协程。
创建协程的语法格式如下:
使用 go 关键字创建协程时,被调用函数的返回值会被忽略。
例如,使用 go 关键字,将 running() 函数并发执行,每隔一秒打印一次计数器,而 main 的协程则等待用户输入,两个行为可以同时进行。
在以上代码中:
在本例中,Go 程序在启动时,运行时(runtime)会默认为 main() 函数创建一个协程。在 main() 函数的协程中执行到 go running 语句时,归属于 running() 函数的协程被创建,running() 函数开始在自己的协程中执行。此时,main() 函数继续执行,两个协程通过 Go 程序的调度机制同时运作。
另外,还可以使用匿名函数来创建协程。
使用匿名函数创建协程的语法格式如下:
例如,在 main() 函数中创建一个匿名函数,并为匿名函数启动协程。匿名函数没有参数。
注意,所有协程在 main() 函数结束时会一同结束。协程虽然类似于线程概念,但是从调度性能上没有线程细致,而细致程度取决于 Go 程序的协程调度器的实现和运行环境。终止协程的最好方法就是自然返回协程对应的函数。
事实上,不管是什么平台,什么编程语言,不管在哪里,并发都是一个大话题,话题大小通常也直接对应于问题的大小。并发编程的难度在于协调,而协调就要通过交流,从这个角度来看,并发单元间的通信是最大的问题。
通常有两种最常见的并发通信模型:共享数据和消息。
共享数据是指多个并发单元分别保持对同一个数据的引用,实现对该数据的共享。被共享的数据可能有多种形式,如内存数据块、磁盘文件、网络数据等。在实际工程应用中最常见的是内存,也就是常说的共享内存。
在主函数中,使用 for 循环来不断检查 counter 的值(同时需要加锁)。当其值达到 10 时,说明所有协程都执行完毕了,这时主函数返回,程序退出。
在以上代码中实现的功能非常简单,但是却使用了很复杂的代码去编写,Go 语言既然以并发编程作为语言的核心优势,当然不至于将这样的问题用这么复杂的方式来解决。
Go 语言提供的是另一种通信模型,即以消息机制而非共享内存作为通信方式。
消息机制认为每个并发单元是自包含的、独立的个体,并且都有自己的变量,但在不同并发单元之间这些变量不共享。每个并发单元的输入和输出只有一种,那就是消息。这有点类似于进程的概念,每个进程不会被其他进程打扰,它只做好自己的工作就可以了,不同进程间靠消息来通信,它们不会共享内存。
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
...
在以上代码中:
- 第 9 行,使用 for 语句形成一个无限循环;
- 第 10 行,times 变量在循环中不断自增;
- 第 11 行,输出 times 变量的值;
- 第 13 行,使用 time.Sleep 暂停 1 秒后继续循环;
- 第 18 行,使用 go 关键字让 running() 函数并发运行;
- 第 21 行,接收用户输入,直到按 Enter 键时将输入的内容写入 input 变量中并返回,整个程序终止。
在本例中,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) }在以上代码中:
- 第 7 行,go 后面接匿名函数,启动协程;
- 第 8~13 行的逻辑与前面程序的 running() 函数一致;
- 第 14 行的括号的功能是调用匿名函数的参数列表。由于第 7 行的匿名函数没有参数,因此第 14 行的参数列表也是空的。
注意,所有协程在 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
在主函数中,使用 for 循环来不断检查 counter 的值(同时需要加锁)。当其值达到 10 时,说明所有协程都执行完毕了,这时主函数返回,程序退出。
在以上代码中实现的功能非常简单,但是却使用了很复杂的代码去编写,Go 语言既然以并发编程作为语言的核心优势,当然不至于将这样的问题用这么复杂的方式来解决。
Go 语言提供的是另一种通信模型,即以消息机制而非共享内存作为通信方式。
消息机制认为每个并发单元是自包含的、独立的个体,并且都有自己的变量,但在不同并发单元之间这些变量不共享。每个并发单元的输入和输出只有一种,那就是消息。这有点类似于进程的概念,每个进程不会被其他进程打扰,它只做好自己的工作就可以了,不同进程间靠消息来通信,它们不会共享内存。
Go语言提供的消息通信机制被称为通道(channel),我会专门写一篇文章进行详细地讲解。注意:不要通过共享内存来通信,而应该通过通信来共享内存。