Go第九篇之并发大攻略

并发指在同一时间内可以执行多个任务。并发编程含义比较广泛,包含多线程编程、多进程编程及分布式程序等

 

Go 语言通过编译器运行时(runtime),从语言上支持了并发的特性。Go 语言的并发通过 goroutine 特性完成。goroutine 类似于线程,但是可以根据需要创建多个 goroutine 并发工作。goroutine 是由 Go 语言的运行时调度完成,而线程是由操作系统调度完成
Go 语言还提供 channel 在多个 goroutine 间进行通信。goroutine 和 channel 是 Go 语言秉承的 CSP(Communicating Sequential Process)并发模式的重要实现基础。本章中,将详细为大家讲解 goroutine 和 channel 及相关特性。

Go语言轻量级线程

在编写 Socket 网络程序时,需要提前准备一个线程池为每一个 Socket 的收发包分配一个线程。开发人员需要在线程数量和 CPU 数量间建立一个对应关系,以保证每个任务能及时地被分配到 CPU 上进行处理,同时避免多个任务频繁地在线程间切换执行而损失效率。
虽然,线程池为逻辑编写者提供了线程分配的抽象机制。但是,如果面对随时随地可能发生的并发和线程处理需求,线程池就不是非常直观和方便了。能否有一种机制:使用者分配足够多的任务,系统能自动帮助使用者把任务分配到 CPU 上,让这些任务尽量并发运作。这种机制在 Go 语言中被称为 goroutine。
goroutine 的概念类似于线程,但 goroutine 由 Go 程序运行时的调度和管理。Go 程序会智能地将 goroutine 中的任务合理地分配给每个 CPU
Go 程序从 main 包的 main() 函数开始,在程序启动时,Go 程序就会为 main() 函数创建一个默认的 goroutine。

使用普通函数创建 goroutine

Go 程序中使用 go 关键字为一个函数创建一个 goroutine。一个函数可以被创建多个 goroutine,一个 goroutine 必定对应一个函数。

格式

为一个普通函数创建 goroutine 的写法如下:

go 函数名( 参数列表 )

  • 函数名:要调用的函数名。
  • 参数列表:调用函数需要传入的参数。

使用 go 关键字创建 goroutine 时,被调用函数的返回值会被忽略
如果需要在 goroutine 中返回数据,请使用后面介绍的通道(channel)特性,通过通道把数据从 goroutine 中作为返回值传出

例子

使用 go 关键字,将 running() 函数并发执行,每隔一秒打印一次计数器,而 main 的 goroutine 则等待用户输入,两个行为可以同时进行。请参考下面代码:

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,同时可以使用 fmt.Scanln() 接受用户输入。两个环节可以同时进行。
代码说明如下:
第 12 行,使用 for 形成一个无限循环。
第 13 行,times 变量在循环中不断自增。
第 14 行,输出 times 变量的值。
第 17 行,使用 time.Sleep 暂停 1 秒后继续循环。
第 25 行,使用 go 关键字让 running() 函数并发运行。
第 29 行,接受用户输入,直到按 Enter 键时将输入的内容写入 input 变量中并返回,整个程序终止。

这段代码的执行顺序如下图所示。


图:并发运行图

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

使用匿名函数创建goroutine

go 关键字后也可以为匿名函数或闭包启动 goroutine

使用匿名函数创建goroutine的格式

使用匿名函数或闭包创建 goroutine 时,除了将函数定义部分写在 go 的后面之外,还需要加上匿名函数的调用参数,格式如下

go func( 参数列表 ){
    函数体
}( 调用参数列表 )

其中:

  • 参数列表:函数体内的参数变量列表。
  • 函数体:匿名函数的代码。
  • 调用参数列表:启动 goroutine 时,需要向匿名函数传递的调用参数。

使用匿名函数创建goroutine的例子

在 main() 函数中创建一个匿名函数并为匿名函数启动 goroutine。匿名函数没有参数。代码将并行执行定时打印计数的效果。

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)
}

代码说明如下:

  • 第 10 行,go 后面接匿名函数启动 goroutine。
  • 第 12~19 行的逻辑与前面程序的 running() 函数一致。
  • 第 21 行的括号的功能是调用匿名函数的参数列表。由于第 10 行的匿名函数没有参数,因此第 21 行的参数列表也是空的。

提示

所有 goroutine 在 main() 函数结束时会一同结束。
goroutine 虽然类似于线程概念,但是从调度性能上没有线程细致,而细致程度取决于 Go 程序的 goroutine 调度器的实现和运行环境
终止 goroutine 的最好方法就是自然返回 goroutine 对应的函数。虽然可以用 golang.org/x/net/context 包进行 goroutine 生命期深度控制,但这种方法仍然处于内部试验阶段,并不是官方推荐的特性
截止 Go 1.9 版本,暂时没有标准接口获取 goroutine 的 ID。

Go语言调整并发的运行性能

在 Go 程序运行时(runtime)实现了一个小型的任务调度器。这套调度器的工作原理类似于操作系统调度线程,Go 程序调度器可以高效地将 CPU 资源分配给每一个任务。传统逻辑中,开发者需要维护线程池中线程与 CPU 核心数量的对应关系。同样的,Go 地中也可以通过 runtime.GOMAXPROCS() 函数做到,格式为:

runtime.GOMAXPROCS(逻辑CPU数量)

这里的逻辑CPU数量可以有如下几种数值:

  • <1:不修改任何数值。
  • =1:单核心执行。
  • >1:多核并发执行。

一般情况下,可以使用 runtime.NumCPU() 查询 CPU 数量,并使用 runtime.GOMAXPROCS() 函数进行设置,例如:

runtime.GOMAXPROCS(runtime.NumCPU())

Go 1.5 版本之前,默认使用的是单核心执行。从 Go 1.5 版本开始,默认执行上面语句以便让代码并发执行,最大效率地利用 CPU。
GOMAXPROCS 同时也是一个环境变量,在应用程序启动前设置环境变量也可以起到相同的作用。

并发和并行的区别

在讲解并发概念时,总会涉及另外一个概念并行。下面让我们来了解并发和并行之间的区别。

  • 并发(concurrency):把任务在不同的时间点交给处理器进行处理。在同一时间点,任务并不会同时运行。
  • 并行(parallelism):把每一个任务分配给每一个处理器独立完成。在同一时间点,任务一定是同时运行。

两个概念的区别是:任务是否同时执行。举一个生活中的例子:打电话和吃饭。
吃饭时,电话来了,需要停止吃饭去接电话。电话接完后回来继续吃饭,这个过程是并发执行。
吃饭时,电话来了,边吃饭边接电话。这个过程是并行执行。
GO 语言在 GOMAXPROCS 数量与任务数量相等时,可以做到并行执行,但一般情况下都是并发执行。

goroutine和coroutine的区别

C#、Lua、Python 语言都支持 coroutine 特性。coroutine 与 goroutine 在名字上类似,都可以将函数或者语句在独立的环境中运行,但是它们之间有两点不同:

  • goroutine 可能发生并行执行;
  • 但 coroutine 始终顺序执行。

狭义地说,goroutine 可能发生在多线程环境下,goroutine 无法控制自己获取高优先度支持;coroutine 始终发生在单线程,coroutine 程序需要主动交出控制权,宿主才能获得控制权并将控制权交给其他 coroutine。
goroutine 间使用 channel 通信,coroutine 使用 yield 和 resume 操作
goroutine 和 coroutine 的概念和运行机制都是脱胎于早期的操作系统。
coroutine 的运行机制属于协作式任务处理,早期的操作系统要求每一个应用必须遵守操作系统的任务处理规则,应用程序在不需要使用 CPU 时,会主动交出 CPU 使用权。如果开发者无意间或者故意让应用程序长时间占用 CPU,操作系统也无能为力,表现出来的效果就是计算机很容易失去响应或者死机。
goroutine 属于抢占式任务处理,已经和现有的多线程和多进程任务处理非常类似。应用程序对 CPU 的控制最终还需要由操作系统来管理,操作系统如果发现一个应用程序长时间大量地占用 CPU,那么用户有权终止这个任务。

Go语言通道(chan)

单纯地将函数并发执行是没有意义的。函数与函数间需要交换数据才能体现并发执行函数的意义。虽然可以使用共享内存进行数据交换,但是共享内存在不同的 goroutine 中容易发生竞态问题。为了保证数据交换的正确性,必须使用互斥量对内存进行加锁,这种做法势必造成性能问题。

Go 语言提倡使用通信的方法代替共享内存,这里通信的方法就是使用通道(channel),如下图所示。

在地铁站、食堂、洗手间等公共场所人很多的情况下,大家养成了排队的习惯,目的也是避免拥挤、插队导致的低效的资源使用和交换过程。代码与数据也是如此,多个 goroutine 为了争抢数据,势必造成执行的低效率,使用队列的方式是最高效的,channel 就是一种队列一样的结构。

通道的特性

Go 语言中的通道(channel)是一种特殊的类型。在任何时候,同时只能有一个 goroutine 访问通道进行发送和获取数据。goroutine 间通过通道就可以通信。
通道像一个传送带或者队列,总是遵循先入先出(First In First Out)的规则,保证收发数据的顺序。

声明通道类型

通道本身需要一个类型进行修饰,就像切片类型需要标识元素类型。通道的元素类型就是在其内部传输的数据类型,声明如下:

var 通道变量 chan 通道类型

  • 通道类型:通道内的数据类型。
  • 通道变量:保存通道的变量。

chan 类型的空值是 nil,声明后需要配合 make 后才能使用。

创建通道

通道是引用类型,需要使用 make 进行创建,格式如下:

通道实例 := make(chan 数据类型)

  • 数据类型:通道内传输的元素类型。
  • 通道实例:通过make创建的通道句柄。

请看下面的例子

ch1 := make(chan int)                 // 创建一个整型类型的通道
ch2 := make(chan interface{})         // 创建一个空接口类型的通道, 可以存放任意格式

type Equip struct{ /* 一些字段 */ }
ch2 := make(chan *Equip)             // 创建Equip指针类型的通道, 可以存放*Equip

使用通道发送数据

通道创建后,就可以使用通道进行发送和接收操作。

通道发送数据的格式

通道的发送使用特殊的操作符<-,将数据通过通道发送的格式为:

通道变量 <- 值

  • 通道变量:通过make创建好的通道实例。
  • 值:可以是变量、常量、表达式或者函数返回值等。值的类型必须与ch通道的元素类型一致。

通过通道发送数据的例子

使用 make 创建一个通道后,就可以使用<-向通道发送数据,代码如下:

// 创建一个空接口通道
ch := make(chan interface{})
// 将0放入通道中
ch <- 0
// 将hello字符串放入通道中
ch <- "hello"

发送将持续阻塞直到数据被接收

把数据往通道中发送时,如果接收方一直都没有接收,那么发送操作将持续阻塞。Go 程序运行时能智能地发现一些永远无法发送成功的语句并做出提示,代码如下:

package main

func main() {
    // 创建一个整型通道
    ch := make(chan int)

    // 尝试将0通过通道发送
    ch <- 0
}

运行代码,报错:

fatal error: all goroutines are asleep - deadlock!

报错的意思是:运行时发现所有的 goroutine(包括main)都处于等待 goroutine。也就是说所有 goroutine 中的 channel 并没有形成发送和接收对应的代码。

使用通道接收数据

通道接收同样使用<-操作符,通道接收有如下特性:
① 通道的收发操作在不同的两个 goroutine 间进行。
由于通道的数据在没有接收方处理时,数据发送方会持续阻塞,因此通道的接收必定在另外一个 goroutine 中进行。
② 接收将持续阻塞直到发送方发送数据。
如果接收方接收时,通道中没有发送方发送数据,接收方也会发生阻塞,直到发送方发送数据为止。
③ 每次接收一个元素。
通道一次只能接收一个数据元素。
通道的数据接收一共有以下 4 种写法。

1) 阻塞接收数据

阻塞模式接收数据时,将接收变量作为<-操作符的左值,格式如下:

data := <-ch

执行该语句时将会阻塞,直到接收到数据并赋值给 data 变量。

2) 非阻塞接收数据

使用非阻塞方式从通道接收数据时,语句不会发生阻塞,格式如下:

data, ok := <-ch

  • data:表示接收到的数据。未接收到数据时,data 为通道类型的零值。
  • ok:表示是否接收到数据。

非阻塞的通道接收方法可能造成高的 CPU 占用,因此使用非常少。如果需要实现接收超时检测,可以配合 select 和计时器 channel 进行,可以参见后面的内容。

3) 接收任意数据,忽略接收的数据

阻塞接收数据后,忽略从通道返回的数据,格式如下:

<-ch

执行该语句时将会发生阻塞,直到接收到数据,但接收到的数据会被忽略。这个方式实际上只是通过通道在 goroutine 间阻塞收发实现并发同步。
使用通道做并发同步的写法,可以参考下面的例子

package main
 
import (
"fmt"
)
 
func main() {
 
// 构建一个通道
ch := make(chan int)
 
// 开启一个并发匿名函数
go func() {
 
fmt.Println("start goroutine")
 
// 通过通道通知main的goroutine
ch <- 0
 
fmt.Println("exit goroutine")
 
}()
 
fmt.Println("wait goroutine")
 
// 等待匿名goroutine
<-ch
 
fmt.Println("all done")
 
}

执行代码,输出如下:
wait goroutine
start goroutine
exit goroutine
all done

代码说明如下:

  • 第 10 行,构建一个同步用的通道。
  • 第 13 行,开启一个匿名函数的并发。
  • 第 18 行,匿名 goroutine 即将结束时,通过通道通知 main 的 goroutine,这一句会一直阻塞直到 main 的 goroutine 接收为止。
  • 第 27 行,开启 goroutine 后,马上通过通道等待匿名 goroutine 结束。

4) 循环接收

通道的数据接收可以借用 for range 语句进行多个元素的接收操作,格式如下:

for data := range ch {
 
}

通道 ch 是可以进行遍历的,遍历的结果就是接收到的数据。数据类型就是通道的数据类型。通过for遍历获得的变量只有一个,即上面例子中的 data。
遍历通道数据的例子请参考下面的代码。
使用 for 从通道中接收数据

func main() {
	ch := make(chan int)
	go func() {

		for i := 3; i > 0; i-- {
			ch <- i
			time.Sleep(time.Second)
		}

	}()

	for data := range ch {
		fmt.Println(data)

		if data == 0 {
			break
		}
	}
}
/* 3 2 1 fatal error: all goroutines are asleep - deadlock! goroutine 1 [chan receive]: main.main() F:/GoWorks/src/codingre/day6/307.go:27 +0x7b */

代码说明如下:

    • 第 12 行,通过 make 生成一个整型元素的通道。
    • 第 15 行,将匿名函数并发执行。
    • 第 18 行,用循环生成 3 到 0 之间的数值。
    • 第 21 行,将 3 到 0 之间的数值依次发送到通道 ch 中。
    • 第 24 行,每次发送后暂停 1 秒。
    • 第 30 行,使用 for 从通道中接收数据。
    • 第 33 行,将接收到的数据打印出来。
    • 第 36 行,当接收到数值 0 时,停止接收。如果继续发送,由于接收 goroutine 已经退出,没有 goroutine 发送到通道,因此运行时将会触发宕机报错。

示例:并发打印

前面的例子创建的都是无缓冲通道。使用无缓冲通道往里面装入数据时,装入方将被阻塞,直到另外通道在另外一个 goroutine 中被取出。同样,如果通道中没有放入任何数据,接收方试图从通道中获取数据时,同样也是阻塞。发送和接收的操作是同步完成的。

下面通过一个并发打印的例子,将 goroutine 和 channel 放在一起展示它们的用法

package main

import "fmt"

//并发打印

func printer(c chan int) {

	//	开始无限循环等待数据
	for {
		//从channel中获取一个数据
		data := <-c
		//将0视为数据结束
		if data == 0 {
			break
		}
		//打印数据
		fmt.Println(data)

	}
	//通知main已经结束循环了(我搞定了)
	c <- 0
}

func main() {
	//创建一个channel
	c := make(chan int)
	// 并发执行printer 传入channel
	go printer(c)
	for i := 1; i <= 10; i++ {
		//将数据通过channel投送给printer
		c <- i
	}
	//通知并发的printer结束循环(没数据啦)
	c <-0
	//等待printer结束(搞定喊我)
	<-c
}
/*
1
2
3
4
5
6
7
8
9
10 */

代码说明如下:

  • 第 10 行,创建一个无限循环,只有当第 16 行获取到的数据为 0 时才会退出循环。
  • 第 13 行,从函数参数传入的通道中获取一个整型数值。
  • 第 21 行,打印整型数值。
  • 第 25 行,在退出循环时,通过通道通知 main() 函数已经完成工作。
  • 第 32 行,创建一个整型通道进行跨 goroutine 的通信。
  • 第 35 行,创建一个 goroutine,并发执行 printer() 函数。
  • 第 37 行,构建一个数值循环,将 1~10 的数通过通道传送给 printer 构造出的 goroutine。
  • 第 44 行,给通道传入一个 0,表示将前面的数据处理完成后,退出循环。
  • 第 47 行,在数据发送过去后,因为并发和调度的原因,任务会并发执行。这里需要等待 printer 的第 25 行返回数据后,才可以退出 main()。

本例的设计模式就是典型的生产者和消费者。生产者是第 37 行的循环,而消费者是 printer() 函数。整个例子使用了两个 goroutine,一个是 main(),一个是通过第 35 行 printer() 函数创建的 goroutine。两个 goroutine 通过第 32 行创建的通道进行通信。这个通道有下面两重功能。

    • 数据传送:第 40 行中发送数据和第 13 行接收数据。
    • 控制指令:类似于信号量的功能。同步 goroutine 的操作。功能简单描述为:
      • 第 44 行:“没数据啦!”
      • 第 25 行:“我搞定了!”
      • 第 47 行:“搞定喊我!”

 Go语言单向通道

Go 的通道可以在声明时约束其操作方向,如只发送或是只接收。这种被约束方向的通道被称做单向通道。

单向通道的声明格式

只能发送的通道类型为chan<-,只能接收的通道类型为<-chan,格式如下

var 通道实例 chan<- 元素类型    // 只能发送通道
var 通道实例 <-chan 元素类型    // 只能接收通道
  • 元素类型:通道包含的元素类型。
  • 通道实例:声明的通道变量。

单向通道的使用例子

示例代码如下:

ch := make(chan int)
// 声明一个只能发送的通道类型, 并赋值为ch var chSendOnly chan<- int = ch //声明一个只能接收的通道类型, 并赋值为ch var chRecvOnly <-chan int = ch

上面的例子中,chSendOnly 只能发送数据,如果尝试接收数据,将会出现如下报错:

invalid operation: <-chSendOnly (receive from send-only type chan<- int)

同理,chRecvOnly 也是不能发送的。

当然,使用 make 创建通道时,也可以创建一个只发送或只读取的通道

ch := make(<-chan int)
var chReadOnly <-chan int = ch
<-chReadOnly

上面代码编译正常,运行也是正确的。但是,一个不能填充数据(发送)只能读取的通道是毫无意义的

time包中的单向通道

time 包中的计时器会返回一个 timer 实例,代码如下

timer := time.NewTimer(time.Second)

timer的Timer类型定义如下

type Timer struct {
C <-chan Time
r runtimeTimer
}

第 2 行中 C 通道的类型就是一种只能接收的单向通道。如果此处不进行通道方向约束,一旦外部向通道发送数据,将会造成其他使用到计时器的地方逻辑产生混乱。

因此,单向通道有利于代码接口的严谨性

 Go语言带缓冲的通道

在无缓冲通道的基础上,为通道增加一个有限大小的存储空间形成带缓冲通道。带缓冲通道在发送时无需等待接收方接收即可完成发送过程,并且不会发生阻塞,只有当存储空间满时才会发生阻塞。同理,如果缓冲通道中有数据,接收时将不会发生阻塞,直到通道中没有数据可读时,通道将会再度阻塞。

无缓冲通道保证收发过程同步。无缓冲收发过程类似于快递员给你电话让你下楼取快递,整个递交快递的过程是同步发生的,你和快递员不见不散。但这样做快递员就必须等待所有人下楼完成操作后才能完成所有投递工作。如果快递员将快递放入快递柜中,并通知用户来取,快递员和用户就成了异步收发过程,效率可以有明显的提升。带缓冲的通道就是这样的一个“快递柜”。

创建带缓冲通道

如何创建带缓冲的通道呢?参见如下代码:

通道实例 := make(chan 通道类型, 缓冲大小)

  • 通道类型:和无缓冲通道用法一致,影响通道发送和接收的数据类型。
  • 缓冲大小:决定通道最多可以保存的元素数量。
  • 通道实例:被创建出的通道实例。


下面通过一个例子中来理解带缓冲通道的用法,参见下面的代码:

package main
import "fmt"
func main() {
// 创建一个3个元素缓冲大小的整型通道
ch := make(chan int, 3)
// 查看当前通道的大小
fmt.Println(len(ch))
// 发送3个整型元素到通道
ch <- 1
ch <- 2
ch <- 3
// 查看当前通道的大小
fmt.Println(len(ch))
}

代码输出如下:
0
3

代码说明如下:

  • 第 8 行,创建一个带有 3 个元素缓冲大小的整型类型的通道。
  • 第 11 行,查看当前通道的大小。带缓冲的通道在创建完成时,内部的元素是空的,因此使用 len() 获取到的返回值为 0。
  • 第 14~16 行,发送 3 个整型元素到通道。因为使用了缓冲通道。即便没有 goroutine 接收,发送者也不会发生阻塞。
  • 第 19 行,由于填充了 3 个通道,此时的通道长度变为 3。

阻塞条件

带缓冲通道在很多特性上和无缓冲通道是类似的。无缓冲通道可以看作是长度永远为 0 的带缓冲通道。因此根据这个特性,带缓冲通道在下面列举的情况下依然会发生阻塞:

  • 带缓冲通道被填满时,尝试再次发送数据时发生阻塞。
  • 带缓冲通道为空时,尝试接收数据时发生阻塞。

为什么Go语言对通道要限制长度而不提供无限长度的通道?

我们知道通道(channel)是在两个 goroutine 间通信的桥梁。使用 goroutine 的代码必然有一方提供数据,一方消费数据。当提供数据一方的数据供给速度大于消费方的数据处理速度时,如果通道不限制长度,那么内存将不断膨胀直到应用崩溃。因此,限制通道的长度有利于约束数据提供方的供给速度,供给数据量必须在消费方处理量+通道长度的范围内,才能正常地处理数据。

Go语言通道的多路复用

多路复用是通信和网络中的一个专业术语。多路复用通常表示在一个信道上传输多路信号或数据流的过程和技术。

提示

报话机同一时刻只能有一边进行收或者发的单边通信,报话机需要遵守的通信流程如下:

  • 说话方在完成时需要补上一句“完毕”,随后放开通话按钮,从发送切换到接收状态,收听对方说话。
  • 收听方在听到对方说“完毕”时,按下通话按钮,从接收切换到发送状态,开始说话。


电话可以在说话的同时听到对方说话,所以电话是一种多路复用的设备,一条通信线路上可以同时接收或者发送数据。同样的,网线、光纤也都是基于多路复用模式来设计的,网线、光纤不仅可支持同时收发数据,还支持多个人同时收发数据。

在使用通道时,想同时接收多个通道的数据是一件困难的事情。通道在接收数据时,如果没有数据可以接收将会发生阻塞。虽然可以使用如下模式进行遍历,但运行性能会非常差。

for{
// 尝试接收ch1通道
data, ok := <-ch1
// 尝试接收ch2通道
data, ok := <-ch2
// 接收后续通道
…
}

Go 语言中提供了 select 关键字,可以同时响应多个通道的操作。select 的每个 case 都会对应一个通道的收发过程。当收发完成时,就会触发 case 中响应的语句。多个操作在每次 select 中挑选一个进行响应。格式如下:

select{
    case 操作1:
        响应操作1
    case 操作2:
        响应操作2
    …
    default:
        没有操作情况
}
  • 操作1、操作2:包含通道收发语句,请参考下表。

    select 多路复用中可以接收的样式
    操   作 语句示例
    接收任意数据 case <- ch;
    接收变量 case d :=  <- ch;
    发送数据 case ch <- 100;
  • 响应操作1、响应操作2:当操作发生时,会执行对应 case 的响应操作。
  • default:当没有任何操作时,默认执行 default 中的语句。

Go语言模拟远程过程调用

服务器开发中会使用RPC(Remote Procedure Call,远程过程调用)简化进程间通信的过程。RPC 能有效地封装通信过程,让远程的数据收发通信过程看起来就像本地的函数调用一样。
本例中,使用通道代替 Socket 实现 RPC 的过程。客户端与服务器运行在同一个进程,服务器和客户端在两个 goroutine 中运行。
我们先给出完整代码,然后再详细分析每一个部分。

package main
import (
"errors"
"fmt"
"time"
)
// 模拟RPC客户端的请求和接收消息封装
func RPCClient(ch chan string, req string) (string, error) {
// 向服务器发送请求
ch <- req
// 等待服务器返回
select {
case ack := <-ch: // 接收到服务器返回数据
return ack, nil
case <-time.After(time.Second): // 超时
return "", errors.New("Time out")
}
}
// 模拟RPC服务器端接收客户端请求和回应
func RPCServer(ch chan string) {
for {
// 接收客户端请求
data := <-ch
// 打印接收到的数据
fmt.Println("server received:", data)
// 反馈给客户端收到
ch <- "roger"
}
}
func main() {
// 创建一个无缓冲字符串通道
ch := make(chan string)
// 并发执行服务器逻辑
go RPCServer(ch)
// 客户端请求数据和接收数据
recv, err := RPCClient(ch, "hi")
if err != nil {
// 发生错误打印
fmt.Println(err)
} else {
// 正常接收到数据
fmt.Println("client received", recv)
}
}

客户端请求和接收封装

下面的代码封装了向服务器请求数据,等待服务器返回数据,如果请求方超时,该函数还会处理超时逻辑。

模拟 RPC 的代码:

// 模拟RPC客户端的请求和接收消息封装
func RPCClient(ch chan string, req string) (string, error) {
// 向服务器发送请求
ch <- req
// 等待服务器返回
select {
case ack := <-ch: // 接收到服务器返回数据
return ack, nil
case <-time.After(time.Second): // 超时
return "", errors.New("Time out")
}
}

代码说明如下:

  • 第 5 行,模拟 socket 向服务器发送一个字符串信息。服务器接收后,结束阻塞执行下一行。
  • 第 8 行,使用 select 开始做多路复用。注意,select 虽然在写法上和 switch 一样,都可以拥有 case 和 default。但是 select 关键字后面不接任何语句,而是将要复用的多个通道语句写在每一个 case 上,如第 9 行和第 11 行所示。
  • 第 11 行,使用了 time 包提供的函数 After(),从字面意思看就是多少时间之后,其参数是 time 包的一个常量,time.Second 表示 1 秒。time.After 返回一个通道,这个通道在指定时间后,通过通道返回当前时间。
  • 第 12 行,在超时时,返回超时错误。


RPCClient() 函数中,执行到 select 语句时,第 9 行和第 11 行的通道操作会同时开启。如果第 9 行的通道先返回,则执行第 10 行逻辑,表示正常接收到服务器数据;如果第 11 行的通道先返回,则执行第 12 行的逻辑,表示请求超时,返回错误。

服务器接收和反馈数据

服务器接收到客户端的任意数据后,先打印再通过通道返回给客户端一个固定字符串,表示服务器已经收到请求。

// 模拟RPC服务器端接收客户端请求和回应
func RPCServer(ch chan string) {
for {
// 接收客户端请求
data := <-ch
// 打印接收到的数据
fmt.Println("server received:", data)
//向客户端反馈已收到
ch <- "roger"
}
}

代码说明如下:

  • 第 3 行,构造出一个无限循环。服务器处理完客户端请求后,通过无限循环继续处理下一个客户端请求。
  • 第 5 行,通过字符串通道接收一个客户端的请求。
  • 第 8 行,将接收到的数据打印出来。
  • 第 11 行,给客户端反馈一个字符串。


运行整个程序,客户端可以正确收到服务器返回的数据,客户端 RPCClient() 函数的代码按下面代码中加粗部分的分支执行。

// 等待服务器返回
select {
case ack := <-ch: // 接收到服务器返回数据
return ack, nil
case <-time.After(time.Second): // 超时
return "", errors.New("Time out")
}

程序输出如下:
server received: hi
client received roger

模拟超时

上面的例子虽然有客户端超时处理,但是永远不会触发,因为服务器的处理速度很快,也没有真正的网络延时或者“服务器宕机”的情况。因此,为了展示 select 中超时的处理,在服务器逻辑中增加一条语句,故意让服务器延时处理一段时间,造成客户端请求超时,代码如下:

// 模拟RPC服务器端接收客户端请求和回应
func RPCServer(ch chan string) {
for {
// 接收客户端请求
data := <-ch
// 打印接收到的数据
fmt.Println("server received:", data)
// 通过睡眠函数让程序执行阻塞2秒的任务
time.Sleep(time.Second * 2)
// 反馈给客户端收到
ch <- "roger"
}
}

第 11 行中,time.Sleep() 函数会让 goroutine 执行暂停 2 秒。使用这种方法模拟服务器延时,造成客户端超时。客户端处理超时 1 秒时通道就会返回:

  1. // 等待服务器返回
  2. select {
  3. case ack := <-ch: // 接收到服务器返回数据
  4. return ack, nil
  5. case <-time.After(time.Second): // 超时
  6. return "", errors.New("Time out")
  7. }

上面代码中,加黑部分的代码就会被执行。

主流程

主流程中会创建一个无缓冲的字符串格式通道。将通道传给服务器的 RPCServer() 函数,这个函数并发执行。使用 RPCClient() 函数通过 ch 对服务器发出 RPC 请求,同时接收服务器反馈数据或者等待超时。参考下面代码:

func main() {
// 创建一个无缓冲字符串通道
ch := make(chan string)
// 并发执行服务器逻辑
go RPCServer(ch)
// 客户端请求数据和接收数据
recv, err := RPCClient(ch, "hi")
if err != nil {
// 发生错误打印
fmt.Println(err)
} else {
// 正常接收到数据
fmt.Println("client received", recv)
}
}

代码说明如下:

    • 第 4 行,创建无缓冲的字符串通道,这个通道用于模拟网络和 socke t概念,既可以从通道接收数据,也可以发送。
    • 第 7 行,并发执行服务器逻辑。服务器一般都是独立进程的,这里使用并发将服务器和客户端逻辑同时在一个进程内运行。
    • 第 10 行,使用 RPCClient() 函数,发送“hi”给服务器,同步等待服务器返回。
    • 第 13 行,如果通信过程发生错误,打印错误。
    • 第 16 行,正常接收时,打印收到的数据。

示例:使用通道响应计时器的事件 

Go 语言中的 time 包提供了计时器的封装。由于 Go 语言中的通道和 goroutine 的设计,定时任务可以在 goroutine 中通过同步的方式完成,也可以通过在 goroutine 中异步回调完成。这里将分两种用法进行例子展示。

一段时间之后(time.After)

延迟回调:

package main
import (
"fmt"
"time"
)
func main() {
// 声明一个退出用的通道
exit := make(chan int)
// 打印开始
fmt.Println("start")
// 过1秒后, 调用匿名函数
time.AfterFunc(time.Second, func() {
// 1秒后, 打印结果
fmt.Println("one second after")
// 通知main()的goroutine已经结束
exit <- 0
})
// 等待结束
<-exit
}

代码说明如下:

  • 第 10 行,声明一个退出用的通道,往这个通道里写数据表示退出。
  • 第 16 行,调用 time.AfterFunc() 函数,传入等待的时间和一个回调。回调使用一个匿名函数,在时间到达后,匿名函数会在另外一个 goroutine 中被调用。
  • 第 22 行,任务完成后,往退出通道中写入数值表示需要退出。
  • 第 26 行,运行到此处时持续阻塞,直到 1 秒后第 22 行被执行后结束阻塞。


time.AfterFunc() 函数是在 time.After 基础上增加了到时的回调,方便使用。

而 time.After() 函数又是在 time.NewTimer() 函数上进行的封装,下面的例子展示如何使用 timer.NewTimer() 和 time.NewTicker()。

定点计时

计时器(Timer)的原理和倒计时闹钟类似,都是给定多少时间后触发。打点器(Ticker)的原理和钟表类似,钟表每到整点就会触发。这两种方法创建后会返回 time.Ticker 对象和 time.Timer 对象,里面通过一个 C 成员,类型是只能接收的时间通道(<-chan Time),使用这个通道就可以获得时间触发的通知。

下面代码创建一个打点器,每 500 毫秒触发一起;创建一个计时器,2 秒后触发,只触发一次。

计时器:

package main
import (
"fmt"
"time"
)
func main() {
// 创建一个打点器, 每500毫秒触发一次
ticker := time.NewTicker(time.Millisecond * 500)
// 创建一个计时器, 2秒后触发
stopper := time.NewTimer(time.Second * 2)
// 声明计数变量
var i int
// 不断地检查通道情况
for {
// 多路复用通道
select {
case <-stopper.C: // 计时器到时了
fmt.Println("stop")
// 跳出循环
goto StopHere
case <-ticker.C: // 打点器触发了
// 记录触发了多少次
i++
fmt.Println("tick", i)
}
}
// 退出的标签, 使用goto跳转
StopHere:
fmt.Println("done")
}

代码说明如下:

    • 第 11 行,创建一个打点器,500 毫秒触发一次,返回 *time.Ticker 类型变量。
    • 第 14 行,创建一个计时器,2 秒后返回,返回 *time.Timer 类型变量。
    • 第 17 行,声明一个变量,用于累计打点器触发次数。
    • 第 20 行,每次触发后,select 会结束,需要使用循环再次从打点器返回的通道中获取触发通知。
    • 第 23 行,同时等待多路计时器信号。
    • 第 24 行,计时器信号到了。
    • 第 29 行,通过 goto 跳出循环。
    • 第 31 行,打点器信号到了,通过i自加记录触发次数并打印。

Go语言关闭通道后继续使用通道

通道是一个引用对象,和 map 类似。map 在没有任何外部引用时,Go 程序在运行时(runtime)会自动对内存进行垃圾回收(Garbage Collection, GC)。类似的,通道也可以被垃圾回收,但是通道也可以被主动关闭。

格式

使用 close() 来关闭一个通道:

close(ch)

关闭的通道依然可以被访问,访问被关闭的通道将会发生一些问题。

给被关闭通道发送数据将会触发panic

被关闭的通道不会被置为 nil。如果尝试对已经关闭的通道进行发送,将会触发宕机,代码如下:

package main
import "fmt"
func main() {
// 创建一个整型的通道
ch := make(chan int)
// 关闭通道
close(ch)
// 打印通道的指针, 容量和长度
fmt.Printf("ptr:%p cap:%d len:%d\n", ch, cap(ch), len(ch))
// 给关闭的通道发送数据
ch <- 1
}

代码运行后触发宕机:

panic: send on closed channel

代码说明如下:

  • 第 7 行,创建一个整型通道。
  • 第 10 行,关闭通道,注意 ch 不会被 close 设置为 nil,依然可以被访问。
  • 第 13 行,打印已经关闭通道的指针、容量和长度。
  • 第 16 行,尝试给已经关闭的通道发送数据。


提示触发宕机的原因是给一个已经关闭的通道发送数据。

从已关闭的通道接收数据时将不会发生阻塞

从已经关闭的通道接收数据或者正在接收数据时,将会接收到通道类型的零值,然后停止阻塞并返回。

操作关闭后的通道

package main
import "fmt"
func main() {
// 创建一个整型带两个缓冲的通道
ch := make(chan int, 2)
// 给通道放入两个数据
ch <- 0
ch <- 1
// 关闭缓冲
close(ch)
// 遍历缓冲所有数据, 且多遍历1个
for i := 0; i < cap(ch)+1; i++ {
// 从通道中取出数据
v, ok := <-ch
// 打印取出数据的状态
fmt.Println(v, ok)
}
}

代码运行结果如下:
0 true
1 true
0 false

代码说明如下:

  • 第 7 行,创建一个能保存两个元素的带缓冲的通道,类型为整型。
  • 第 10 行和第11行,给这个带缓冲的通道放入两个数据。这时,通道装满了。
  • 第 14 行,关闭通道。此时,带缓冲通道的数据不会被释放,通道也没有消失。
  • 第 17 行,cap() 函数可以获取一个对象的容量,这里获取的是带缓冲通道的容量,也就是这个通道在 make 时的大小。虽然此时这个通道的元素个数和容量都是相同的,但是 cap 取出的并不是元素个数。这里多遍历一个元素,故意造成这个通道的超界访问。
  • 第 20 行,从已关闭的通道中获取数据,取出的数据放在 v 变量中,类型为 int。ok 变量的结果表示数据是否获取成功。
  • 第 23 行,将 v 和 ok 变量打印出来。


运行结果前两行正确输出带缓冲通道的数据,表明缓冲通道在关闭后依然可以访问内部的数据。

运行结果第三行的“0 false”表示通道在关闭状态下取出的值。0 表示这个通道的默认值,false 表示没有获取成功,因为此时通道已经空了。我们发现,在通道关闭后,即便通道没有数据,在获取时也不会发生阻塞,但此时取出数据会失败。

Go语言Telnet回音服务器

Telnet 协议是 TCP/IP 协议族中的一种。它允许用户(Telnet客户端)通过一个协商过程与一个远程设备进行通信。本例将使用一部分 Telnet 协议与服务器进行通信。

服务器的网络库为了完整展示自己的代码实现了完整的收发过程,一般比较倾向于使用发送任意封包返回原数据的逻辑。这个过程类似于对着大山高喊,大山把你的声音原样返回的过程。也就是回音(Echo)。本节使用 Go 语言中的 Socket、goroutine 和通道编写一个简单的 Telnet 协议的回音服务器。

回音服务器的代码分为 4 个部分,分别是接受连接、会话处理、Telnet 命令处理和程序入口。

接受连接

回音服务器能同时服务于多个连接。要接受连接就需要先创建侦听器,侦听器需要一个侦听地址和协议类型。就像你想卖东西,需要先确认卖什么东西,卖东西的类型就是协议类型,然后需要一个店面,店面位于街区的某个位置,这就是侦听器的地址。一个服务器可以开启多个侦听器,就像一个街区可以有多个店面。街区上的编号对应的就是地址中的端口号,如下图所示。


图:IP和端口号
 
  • 主机 IP:一般为一个 IP 地址或者域名,127.0.0.1 表示本机地址。
  • 端口号:16 位无符号整型值,一共有 65536 个有效端口号。


通过地址和协议名创建侦听器后,可以使用侦听器响应客户端连接。响应连接是一个不断循环的过程,就像到银行办理业务时,一般是排队处理,前一个人办理完后,轮到下一个人办理。

我们把每个客户端连接处理业务的过程叫做会话。在会话中处理的操作和接受连接的业务并不冲突可以同时进行。就像银行有 3 个窗口,喊号器会将用户分配到不同的柜台。这里的喊号器就是 Accept 操作,窗口的数量就是 CPU 的处理能力。因此,使用 goroutine 可以轻松实现会话处理和接受连接的并发执行。

如下图清晰地展现了这一过程。


图:Socket 处理过程


Go语言中可以根据实际会话数量创建多个goroutine,并自动的调度它们的处理。

telnet 服务器处理

package main

import (
    "fmt"
    "net"
)

// 服务逻辑, 传入地址和退出的通道
func server(address string, exitChan chan int) {

    // 根据给定地址进行侦听
    l, err := net.Listen("tcp", address)

    // 如果侦听发生错误, 打印错误并退出
    if err != nil {
        fmt.Println(err.Error())
        exitChan <- 1
    }

    // 打印侦听地址, 表示侦听成功
    fmt.Println("listen: " + address)

    // 延迟关闭侦听器
    defer l.Close()

    // 侦听循环
    for {

        // 新连接没有到来时, Accept是阻塞的
        conn, err := l.Accept()

        // 发生任何的侦听错误, 打印错误并退出服务器
        if err != nil {
            fmt.Println(err.Error())
            continue
        }

        // 根据连接开启会话, 这个过程需要并行执行
        go handleSession(conn, exitChan)
    }
}

代码说明如下:

  • 第 9 行,接受连接的入口,address 为传入的地址,退出服务器使用 exitChan 的通道控制。往 exitChan 写入一个整型值时,进程将以整型值作为程序返回值来结束服务器。
  • 第 12 行,使用 net 包的 Listen() 函数进行侦听。这个函数需要提供两个参数,第一个参数为协议类型,本例需要做的是 TCP 连接,因此填入“tcp”;address 为地址,格式为“主机:端口号”。
  • 第 15 行,如果侦听发生错误,通过第 17 行,往 exitChan 中写入非 0 值结束服务器,同时打印侦听错误。
  • 第 24 行,使用 defer,将侦听器的结束延迟调用。
  • 第 27 行,侦听开始后,开始进行连接接受,每次接受连接后需要继续接受新的连接,周而复始。
  • 第 30 行,服务器接受了一个连接。在没有连接时,Accept() 函数调用后会一直阻塞。连接到来时,返回 conn 和错误变量,conn 的类型是 *tcp.Conn。
  • 第 33 行,某些情况下,连接接受会发生错误,不影响服务器逻辑,这时重新进行新连接接受。
  • 第 39 行,每个连接会生成一个会话。这个会话的处理与接受逻辑需要并行执行,彼此不干扰。

会话处理

每个连接的会话就是一个接收数据的循环。当没有数据时,调用 reader.ReadString 会发生阻塞,等待数据的到来。一旦数据到来,就可以进行各种逻辑处理。

回音服务器的基本逻辑是“收到什么返回什么”,reader.ReadString 可以一直读取 Socket 连接中的数据直到碰到期望的结尾符。这种期望的结尾符也叫定界符,一般用于将 TCP 封包中的逻辑数据拆分开。下例中使用的定界符是回车换行符(“\r\n”),HTTP 协议也是使用同样的定界符。使用 reader.ReadString() 函数可以将封包简单地拆分开。

如下图所示为 Telnet 数据处理过程。


图:Telnet 数据处理过程


回音服务器需要将收到的有效数据通过 Socket 发送回去。

Telnet会话处理

package main

import (
    "bufio"
    "fmt"
    "net"
    "strings"
)

// 连接的会话逻辑
func handleSession(conn net.Conn, exitChan chan int) {

    fmt.Println("Session started:")

    // 创建一个网络连接数据的读取器
    reader := bufio.NewReader(conn)

    // 接收数据的循环
    for {

        // 读取字符串, 直到碰到回车返回
        str, err := reader.ReadString('\n')

        // 数据读取正确
        if err == nil {

            // 去掉字符串尾部的回车
            str = strings.TrimSpace(str)

            // 处理Telnet指令
            if !processTelnetCommand(str, exitChan) {
                conn.Close()
                break
            }

            // Echo逻辑, 发什么数据, 原样返回
            conn.Write([]byte(str + "\r\n"))

        } else {
            // 发生错误
            fmt.Println("Session closed")
            conn.Close()
            break
        }
    }

}

代码说明如下:

  • 第 11 行是会话入口,传入连接和退出用的通道。handle Session() 函数被并发执行。
  • 第 16 行,使用 bufio 包的 NewReader() 方法,创建一个网络数据读取器,这个 Reader 将输入数据的读取过程进行封装,方便我们迅速获取到需要的数据。
  • 第 19 行,会话处理开始时,从 Socket 连接,通过 reader 读取器读取封包,处理封包后需要继续读取从网络发送过来的下一个封包,因此需要一个会话处理循环。
  • 第 22 行,使用 reader.ReadString() 方法进行封包读取。内部会自动处理粘包过程,直到下一个回车符到达后返回数据。这里认为封包来自 Telnet,每个指令以回车换行符(“\r\n”)结尾。
  • 第 25 行,数据读取正常时,返回 err 为 nil。如果发生连接断开、接收错误等网络错误时,err 就不是 nil 了。
  • 第 28 行,reader.ReadString 读取返回的字符串尾部带有回车符,使用 strings.TrimSpace() 函数将尾部带的回车和空白符去掉。
  • 第 31 行,将 str 字符串传入 Telnet 指令处理函数 processTelnetCommand() 中,同时传入退出控制通道 exitChan。当这个函数返回 false 时,表示需要关闭当前连接。
  • 第 32 行和第 33 行,关闭当前连接并退出会话接受循环。
  • 第 37 行,将有效数据通过 conn 的 Write() 方法写入,同时在字符串尾部添加回车换行符(“\r\n”),数据将被 Socket 发送给连接方。
  • 第 41~43 行,处理当 reader.ReadString() 函数返回错误时,打印错误信息并关闭连接,退出会话并接收循环。

Telnet命令处理

Telnet 是一种协议。在操作系统中可以在命令行使用 Telnet 命令发起 TCP 连接。我们一般用 Telnet 来连接 TCP 服务器,键盘输入一行字符回车后,即被发送到服务器上。

在下例中,定义了以下两个特殊控制指令,用以实现一些功能:

  • 输入“@close”退出当前连接会话。
  • 输入“@shutdown”终止服务器运行。


Telnet命令处理

package main

import (
    "fmt"
    "strings"
)

func processTelnetCommand(str string, exitChan chan int) bool {

    // @close指令表示终止本次会话
    if strings.HasPrefix(str, "@close") {

        fmt.Println("Session closed")

        // 告诉外部需要断开连接
        return false

        // @shutdown指令表示终止服务进程
    } else if strings.HasPrefix(str, "@shutdown") {

        fmt.Println("Server shutdown")

        // 往通道中写入0, 阻塞等待接收方处理
        exitChan <- 0

        // 告诉外部需要断开连接
        return false
    }

    // 打印输入的字符串
    fmt.Println(str)

    return true

}

代码说明如下:

  • 第 8 行,处理 Telnet 命令的函数入口,传入有效字符并退出通道。
  • 第 11~16 行,当输入字符串中包含“@close”前缀时,在第 16 行返回 false,表示需要关闭当前会话。
  • 第 19~27 行,当输入字符串中包含“@shutdown”前缀时,第 24 行将 0 写入 exitChan,表示结束服务器。
  • 第 31 行,没有特殊的控制字符时,打印输入的字符串。

程序入口

Telnet 回音处理主流程:

package main

import (
    "os"
)

func main() {

    // 创建一个程序结束码的通道
    exitChan := make(chan int)

    // 将服务器并发运行
    go server("127.0.0.1:7001", exitChan)

    // 通道阻塞, 等待接收返回值
    code := <-exitChan

    // 标记程序返回值并退出
    os.Exit(code)
}

代码说明如下:

  • 第 10 行,创建一个整型的无缓冲通道作为退出信号。
  • 第 13 行,接受连接的过程可以并发操作,使用 go 将 server() 函数开启 goroutine。
  • 第 16 行,从 exitChan 中取出返回值。如果取不到数据就一直阻塞。
  • 第 19 行,将程序返回值传入 os.Exit() 函数中并终止程序。


编译所有代码并运行,命令行提示如下:

listen: 127.0.0.1:7001

此时,Socket 侦听成功。在操作系统中的命令行中输入:

telnet 127.0.0.1 7001

尝试连接本地的 7001 端口。接下来进入测试服务器的流程。

测试输入字符串

在 Telnet 连接后,输入字符串 hello,Telnet 命令行显示如下:

$ telnet 127.0.0.1 7001
Trying 127.0.0.1...
Connected to 127.0.0.1.
Escape character is '^]'.
hello
hello

服务器显示如下:

listen: 127.0.0.1:7001
Session started:
hello

客户端输入的字符串会在服务器中显示,同时客户端也会收到自己发给服务器的内容,这就是一次回音。

测试关闭会话

当输入 @close 时,Telnet 命令行显示如下:

@close
Connection closed by foreign host

服务器显示如下:

Session closed

此时,客户端 Telnet 与服务器断开连接。

测试关闭服务器

当输入 @shutdown 时,Telnet 命令行显示如下:

@shutdown
Connection closed by foreign host

服务器显示如下:

Server shutdown

此时服务器会自动关闭。

检测代码在并发环境下可能出现的问题

Go 程序可以使用通道进行多个 goroutine 间的数据交换,但这仅仅是数据同步中的一种方法。通道内部的实现依然使用了各种锁,因此优雅代码的代价是性能。在某些轻量级的场合,原子访问(atomic包)、互斥锁(sync.Mutex)以及等待组(sync.WaitGroup)能最大程度满足需求。

本节只讲解原子访问,互斥锁和等待组将在接下来的两节中讲解。

当多线程并发运行的程序竞争访问和修改同一块资源时,会发生竞态问题。

下面的代码中有一个 ID 生成器,每次调用生成器将会生成一个不会重复的顺序序号,使用 10 个并发生成序号,观察 10 个并发后的结果。

竞态检测的具体代码

package main
import (
"fmt"
"sync/atomic"
)
var (
// 序列号
seq int64
)
// 序列号生成器
func GenID() int64 {
// 尝试原子的增加序列号
atomic.AddInt64(&seq, 1)
return seq
}
func main() {
//生成10个并发序列号
for i := 0; i < 10; i++ {
go GenID()
}
fmt.Println(GenID())
}

代码说明如下:

  • 第 10 行,序列号生成器中的保存上次序列号的变量。
  • 第 17 行,使用原子操作函数 atomic.AddInt64() 对 seq() 函数加 1 操作。不过这里故意没有使用 atomic.AddInt64() 的返回值作为 GenID() 函数的返回值,因此会造成一个竞态问题。
  • 第 25 行,循环 10 次生成 10 个 goroutine 调用 GenID() 函数,同时忽略 GenID() 的返回值。
  • 第 28 行,单独调用一次 GenID() 函数。


在运行程序时,为运行参数加入-race参数,开启运行时(runtime)对竞态问题的分析,命令如下:

go run -race racedetect.go

代码运行发生宕机,输出信息如下:

==================
WARNING: DATA RACE
Write at 0x000000f52f40 by goroutine 7:
  sync/atomic.AddInt64()
      C:/Go/src/runtime/race_amd64.s:276 +0xb
  main.GenID()
      racedetect.go:17 +0x4a

Previous read at 0x000000f52f40 by goroutine 6:
  main.GenID()
      racedetect.go:18 +0x5a

Goroutine 7 (running) created at:
  main.main()
      racedetect.go:25 +0x5a

Goroutine 6 (finished) created at:
  main.main()
      racedetect.go:25 +0x5a
==================
10
Found 1 data race(s)
exit status 66

根据报错信息,第 18 行有竞态问题,根据 atomic.AddInt64() 的参数声明,这个函数会将修改后的值以返回值方式传出。下面代码对加粗部分进行了修改:

  1. func GenID() int64 {
  2. // 尝试原子的增加序列号
  3. return atomic.AddInt64(&seq, 1)
  4. }

再次运行:

go run -race main.go

代码输出如下:
10

没有发生竞态问题,程序运行正常。

本例中只是对变量进行增减操作,虽然可以使用互斥锁(sync.Mutex)解决竞态问题,但是对性能消耗较大。在这种情况下,推荐使用原子操作(atomic)进行变量操作。

互斥锁和读写互斥锁

互斥锁是一种常用的控制共享资源访问的方法,它能够保证同时只有一个 goroutine 可以访问共享资源。在 Go 程序中的使用非常简单,参见下面的代码:

package main
import (
"fmt"
"sync"
)
var (
// 逻辑中使用的某个变量
count int
// 与变量对应的使用互斥锁
countGuard sync.Mutex
)
func GetCount() int {
// 锁定
countGuard.Lock()
// 在函数退出时解除锁定
defer countGuard.Unlock()
return count
}
func SetCount(c int) {
countGuard.Lock()
count = c
countGuard.Unlock()
}
func main() {
// 可以进行并发安全的设置
SetCount(1)
// 可以进行并发安全的获取
fmt.Println(GetCount())
}

代码说明如下:

  • 第 10 行是某个逻辑步骤中使用到的变量,无论是包级的变量还是结构体成员字段,都可以。
  • 第 13 行,一般情况下,建议将互斥锁的粒度设置得越小越好,降低因为共享访问时等待的时间。这里笔者习惯性地将互斥锁的变量命名为以下格式:

    变量名+Guard

    以表示这个互斥锁用于保护这个变量。
  • 第 16 行是一个获取 count 值的函数封装,通过这个函数可以并发安全的访问变量 count。
  • 第 19 行,尝试对 countGuard 互斥量进行加锁。一旦 countGuard 发生加锁,如果另外一个 goroutine 尝试继续加锁时将会发生阻塞,直到这个 countGuard 被解锁。
  • 第 22 行使用 defer 将 countGuard 的解锁进行延迟调用,解锁操作将会发生在 GetCount() 函数返回时。
  • 第 27 行在设置 count 值时,同样使用 countGuard 进行加锁、解锁操作,保证修改 count 值的过程是一个原子过程,不会发生并发访问冲突。


在读多写少的环境中,可以优先使用读写互斥锁(sync.RWMutex),它比互斥锁更加高效。sync 包中的 RWMutex 提供了读写互斥锁的封装。

我们将互斥锁例子中的一部分代码修改为读写互斥锁,参见下面代码:

var (
// 逻辑中使用的某个变量
count int
// 与变量对应的使用互斥锁
countGuard sync.RWMutex
)
func GetCount() int {
// 锁定
countGuard.RLock()
// 在函数退出时解除锁定
defer countGuard.RUnlock()
return count
}

代码说明如下:

    • 第 6 行,在声明 countGuard 时,从 sync.Mutex 互斥锁改为 sync.RWMutex 读写互斥锁。
    • 第 12 行,获取 count 的过程是一个读取 count 数据的过程,适用于读写互斥锁。在这一行,把 countGuard.Lock() 换做 countGuard.RLock(),将读写互斥锁标记为读状态。如果此时另外一个 goroutine 并发访问了 countGuard,同时也调用了 countGuard.RLock() 时,并不会发生阻塞。
    • 第 15 行,与读模式加锁对应的,使用读模式解锁。

Go语言等待组

除了可以使用通道(channel)和互斥锁进行两个并发程序间的同步外,还可以使用等待组进行多个任务的同步,等待组可以保证在并发环境中完成指定数量的任务

等待组有下面几个方法可用,如下表所示。

等待组的方法
方法名 功能
(wg * WaitGroup) Add(delta int) 等待组的计数器 +1
(wg *WaitGroup) Done() 等待组的计数器 -1
(wg *WaitGroup) Wait() 当等待组计数器不等于 0 时阻塞直到变 0。


等待组内部拥有一个计数器,计数器的值可以通过方法调用实现计数器的增加和减少。当我们添加了 N 个并发任务进行工作时,就将等待组的计数器值增加 N。每个任务完成时,这个值减 1。同时,在另外一个 goroutine 中等待这个等待组的计数器值为 0 时,表示所有任务已经完成。

下面的代码演示了这一过程:

package main
import (
"fmt"
"net/http"
"sync"
)
func main() {
// 声明一个等待组
var wg sync.WaitGroup
// 准备一系列的网站地址
var urls = []string{
"http://www.github.com/",
"https://www.qiniu.com/",
"https://www.golangtc.com/",
}
// 遍历这些地址
for _, url := range urls {
// 每一个任务开始时, 将等待组增加1
wg.Add(1)
// 开启一个并发
go func(url string) {
// 使用defer, 表示函数完成时将等待组值减1
defer wg.Done()
// 使用http访问提供的地址
_, err := http.Get(url)
// 访问完成后, 打印地址和可能发生的错误
fmt.Println(url, err)
// 通过参数传递url地址
}(url)
}
// 等待所有的任务完成
wg.Wait()
fmt.Println("over")
}

代码说明如下:

    • 第 12 行,声明一个等待组,对一组等待任务只需要一个等待组,而不需要每一个任务都使用一个等待组。
    • 第 15 行,准备一系列可访问的网站地址的字符串切片。
    • 第 22 行,遍历这些字符串切片。
    • 第 25 行,将等待组的计数器加1,也就是每一个任务加 1。
    • 第 28 行,将一个匿名函数开启并发。
    • 第 31 行,在匿名函数结束时会执行这一句以表示任务完成。wg.Done() 方法等效于执行 wg.Add(-1)。
    • 第 34 行,使用 http 包提供的 Get() 函数对 url 进行访问,Get() 函数会一直阻塞直到网站响应或者超时。
    • 第 37 行,在网站响应和超时后,打印这个网站的地址和可能发生的错误。
    • 第 40 行,这里将 url 通过 goroutine 的参数进行传递,是为了避免 url 变量通过闭包放入匿名函数后又被修改的问题。
    • 第 44 行,等待所有的网站都响应或者超时后,任务完成,Wait 就会停止阻塞。
posted @ 2019-01-17 14:18  鲸鱼的海老大  阅读(18)  评论(0)    收藏  举报