速学 Go 笔记 (五) : 用 Goroutine 玩转并发编程 | Go 主题月

776 阅读9分钟

Go 提供 goroutine ( 函数体 ) 和 channel ( 通道 ) 来供程序员进行并发编程。在其它语言中很难通过开辟上千个线程来建立一个庞大的系统,但在 Go 语言中,我们可以轻松创建十万,百万乃至千万级别的 goroutine 去执行并发任务。Go 非常提倡基于 CSP ( Communicating Sequential Process,顺序通信模型 ) 理论实现并发 ( 见第八章 ) ,但也提供传统的锁模式实现多 goroutine 之间的数据共享 ( 见第九章 ) 。

第八章 Goroutine & 通道

Go 箴言 —— 不要通过共享内存来通信,通过通信来共享内存。

8.1 Goroutine & go 关键字

如果有两个函数调用互不依赖,则意味这两个调用可以 并发 进行。在这里,可以将 Go 语言的 Goroutine 视作是 mini 版 "线程" ( 但实际上,goroutine 和线程存在非常大的数量差别,详见第 9 章 ) 。

在程序启动时,会有一个 goroutine 运行 main 函数,它被称之为主 goroutine 。如果要另启一个新的 goroutine,这只需要在一系列函数调用之前加上一个 go 关键字。和 defer 一样,go 关键字后面必须跟上一个函数调用,不过你可以将一个 func(){...}() 视作是将要被执行的一段语句块。

在下面的例子中,主函数以低效率的方式计算斐波那契数列,而在另一个 goroutine 当中输出一些信息表示系统仍然在运行 ( 因此这两个工作是同时进行的 ) 。

func fibonacci(x int) int {
	//return fib(x, 1, 1)
	return slowFib(x)
}

// 这种计算方式会传递计算结果,并且是尾递归,效率非常高。
func fib(n, left, right int) int {
	switch {
	case n == 0:
		return left
	default:
		return fib(n-1, right, left+right)
	}
}

// 这种计算方式做了大量重复计算,因此效率非常低。
func slowFib(n int) int {
	switch {
	case n < 2:
		return n
	default:
		return slowFib(n-1) + slowFib(n-2)
	}
}

func main() {

	// 相当于创建了一个 Thread, 然后直接 start().
	// 这个 goroutine 独立于主 goroutine 运行。
	go func(){
		for {
			fmt.Print("still running...\n")
			time.Sleep(250 * time.Millisecond)
		}
	}()

	// 主 goroutine 会持续计算
	fmt.Print(fibonacci(46))

}

需要注意的是,一旦 main 函数执行完毕并返回,所有的 goroutine 都会被强制结束。所以在本文测试一些代码时,不要忘记使用 time.Sleep(...) 或者 func{} 将主 goroutine 挂起或另其空转。

8.2 通道

通道是连接 goroutine 之间的桥梁,每一个通道是一个具体类型的导管,称为通道的元素类型。通道的关键字是 chan,下面借助 make 函数创建了一个元素类型为 intchan

map 散列表相同,make 函数返回的是某个 chan 引用本身。当它被复制或者被传入到某个函数时,调用者和被调用者实际上得到的都是同一个引用。通道的零值是 nil,且通道之间是可以比较的:这仅需要判断两个通道是不是指向同一个引用。

8.2.1 建立双向通道

通道的用途有两个:发送 ( Send ) 消息,或者接收 ( Receive ) 消息,这两个操作统称为 通信。Go 语言引入了 <- 符号来形象地表述通信:ch <- x 表示将值 x 发送到通道 ch ,这个表达式可理解成是一个调用;<-ch 则表示从通道 ch 中接收消息,这个表达式的返回值是通道的元素类型。

ch := make(chan int)
// 在另一个 goroutine 中可以像该 int chan 发送指定类型的消息。
go func(){
	// do something....
	var x = 100
	
	// 表示将变量 x 的值发送到 ch.
	ch <- x

}()

// 在另一个 goroutine 中可以接收 int chan 接收指定类型的消息。
go func(){
    // 表示将 ch 通道的取值赋值给 y.
    var y = <- ch
    fmt.Print(y)
}()

// 这个 goroutine 没有消息可以接收。
go func(){
    // 该 goroutine 运行到这里之后会陷入阻塞。
    var y = <- ch
    
    // 这条 string 永远不会被打印在控制台。
	fmt.Printf("get a new message! %v",y)
}()

// 注意,主 goroutine 一旦退出,所有任务都会结束,因此这里要尝试阻塞它。
fmt.Print(fibonacci(46))

注意,调用 <-ch意味着消费数据 ( 不管有没有使用它 ) ,且每条消息只能被 消费一次 。在这个例子中,第一个 goroutine ( 称它是 "生产者" goroutine ) 发送的 100 已经被另一个 "消费者" goroutine 接收。此时如果又有一个 "消费者" goroutine 调用了 <- ch ,那么它将陷入阻塞状态,直到有其它的 goroutine 向这个 ch 发送了新的消息之后,才会执行读取语句。

顺带一提,可以从通道中只取值而不处理,只是这一条消息会被丢弃

go func(){
    // 从通道中取值,但是不做处理。
    <- ch
	// ...
}()

同样,如果 "生产者" goroutinech 通道发送消息却没有 "消费者" 处理,则它就不会发送这条消息 ( 这很重要,见后文的缓冲通道 )。

go func(){
    // 这条消息 "无人处理"
    ch <- 100

    // 导致这条消息被阻塞。
    ch <- 101
    fmt.Print("程序不会执行到这里。")
}()

目前创建的通道没有缓冲空间,ch 只支持 "现发现取",所以导致任何一方想要发送 / 接收消息时,需要保证旧的消息已经被取走 / 新的消息已经到达,如果不满足条件,则会陷入阻塞状态。因此,没有缓冲空间的通道又被称之为 同步通道

两个 goroutine X,Y 如果使用同步通道收发消息,通常意味着 X,Y 的执行存在着严格的前后顺序。比如 Y 必须等到 X 输入变量之后才可以继续运行,或者说 X 必须先于 Y 处理好某个变量并传递。另一种情况是,它们正以串行受限地方式交互某个受 "同步锁" 保护的共享变量。

如果 X,Y 没有 ( 或无法描述 ) 执行的先后顺序,那么 X 和 Y 就是并发的。我们也会将通信中的每一条消息称作是事件 ( events ) 。当然,通信可以不携带其它信息,仅使用通信动作本身来代表一个任务的完成。

8.2.2 管道

goroutine 之间可以通过 chan 串联起来,从而形成连贯的流水线。这个流水线也可以被称之为 "管道" ( Pipeline ) 。比如:

from := make(chan int)
to := make(chan int)

// 将这个 goroutine 命名为 producer.
go func() {
    for i := 0; i < 100; i++ {
        from <- i
        log.Printf("sent message {%d} to channel [from].", i)
    }
}()

// 将这个 goroutine 命名为 consumer.
// 它将 from 接收的值 x2 之后输入到 to 通道中。
go func() {
    for {
        i := <-from * 2
        to <- i
        log.Printf("convert message to {%d} and send to channel [to]", i)

    }
}()

// 将这个 goroutine 命名为 printer.
// 它输出 to 通道的消息。
go func() {
    for {
        log.Printf("message from channel [to]:{%d}", <-to)
    }
}()

// 阻塞主 goroutine
time.Sleep(500 * time.Second)

三个 goroutine 之间有两个通道 fromto ( 这个命名是以 consumer 的角度出发的 ) 。程序整体运行起来没有什么问题,只不过我们希望做进一步完善:比如:consumer 不应该向 from 通道 "回送" 消息,也不应该向 to 通道 "撤回" 消息。

8.2.3 通过形参列表约束单向通道

限制通道的流向是在函数的形参列表上进行的。"只发" 和 "只取" 的通道在写法上存在差异:

// chan <- int 表示这是一个只发通道,它对应了在双向通道中的发送动作。
// <-chan int  表示这是一个只收通道,它对应了在双向通道中的接收动作。
func pipeline(out chan <- int,in <-chan int){
	// 如果在函数体内错误使用了单向通道,则会在编译期检查出来。
	out <- 100
	<- in
	
	// 错误的用法:
	<- out
	in <- 100
}

双向通道 ( 比如 chan int ) 会通过隐式转换兼容 "只发" ( chan <- int ) 或者 "只取" ( <-chan int) 的通道类型。但反过来,单向通道不能再切换成双向通道。

8.2.4 缓冲通道

可以通过 make 函数主动设置通道的容量。当容量为 0 时,缓冲通道会退化为同步通道。

//  在 make 函数后面传入第二个参数表示创建带长度的缓冲通道。
bufChannel := make(chan string,3)

同步通道和长度为 1 的非缓冲通道之间是存在差别的。前文提到,对于同步通道而言,当 "生产者" 发现没有任何 "消费者" 能够接收这条消息时,"生产者" 就不会发送消息 并立刻阻塞。而对于长度为 1 的非缓冲通道而言,"生产者" 只有在准备发送第二条消息的时候才会开始检查:如果没有 "消费者" ,则等待并阻塞。

当生产者向通道发送消息时,通道的长度加一。同样的,当消费者从通道获取消息时,通道的长度减一。当通道的长度小于容量时,双方可以不受阻塞地收发消息。从逻辑来看,可以将双向通道看作是 FIFO 的队列。

现在,使用 cap(bufChannel) 可以查询缓冲通道的容量,使用 len(bufChannel) 可以查询某个缓冲通道在某一刻的长度。不过,如果一个程序是持续运行的,且处理消息的速度非常迅速,那么长度通常都是浮动的 —— 大部分情况下仅捕获一次长度都没有太大意义,建议捕获多次长度,并通过统计的形式来判断缓冲通道的容量是否设置得合理。

下面的代码块演示了两个几乎是并发执行的两个 goroutine。由于这个程序是 "一次执行" 的,因此 consumer 在通过 len 察觉到通道内没有新消息之后会返回。

ch := make(chan int, 3)

// producer
go func() {
   ch <- 1
   ch <- 2
   ch <- 3
}()

// consumer
go func() {
   // 不能让它在第一个 goroutine 之前运行,否则它会直接退出
   // 因此在这里设置了少量延迟。
   time.Sleep(10 * time.Millisecond)

   for {
      if len(ch) == 0 {break}
      fmt.Print(<-ch,"\n")
   }
}()

time.Sleep(20 * time.Second)

8.2.5 谨防 Goroutine 泄漏

下面的代码创建三个 goroutine 同时向 Baidu,Github,Gitee 三个站点发出请求,但是该函数最终只会返回最先收到的响应。

func cdn() *http.Response{

	resp := make(chan *http.Response)

	go func() {
		get, err := http.Get("https://www.baidu.com")
		if err == nil {
			resp <- get
			fmt.Printf("baidu 请求完毕")
		}
	}()

	go func() {
		get, err := http.Get("https://www.github.com")
		if err == nil {
			resp <- get
			fmt.Printf("github 请求完毕")
		}
	}()

	go func() {
		get, err := http.Get("https://www.gitee.com")
		if err == nil {
			resp <- get
			fmt.Printf("gitee 请求完毕")
		}
	}()
    
	return <-resp
}

从表面上看,调用这个函数不会出现任何问题。但是它的 Bug 是内在的:一旦有任意一个请求返回,意味着 cdn() 函数将立刻返回并退栈。这对那两个稍慢的 goroutine 而言,没有后续的其它 goroutine 接收它们的消息,因此它们会被一直阻塞到主程序退出。调用 cdn() 函数,可以观察到,直到主程序运行完毕,控制台只会打印一行 "XXX 请求完毕"。

这种现象称之为 goroutine 泄漏。这两个泄漏的 goroutine 不会被垃圾回收器回收。对于这个例子而言,有两种方法可以避免 goroutine 泄漏:

第一种思路:任何一方在抢先发送消息之后调用 close(resp) ( 见后文 ) 禁止其它方再次发送,这里依赖原生的同步通道确保同一时刻只有一条消息发送成功。后来的两个 goroutine 一旦尝试发送消息,则会陷入宕机而非阻塞 —— 这个宕机是预期的,这只需要在延迟调用中恢复状态,然后让它们 "若无其事" 地退出即可。

func cdn() *http.Response{

   resp := make(chan *http.Response)

   go func() {
       
      defer func() {
         recover()
         fmt.Printf("其它 goroutine 已经获得了响应,routine1 退出。")
      }()

      get, err := http.Get("https://www.baidu.com")
      if err == nil {
         resp <- get
         fmt.Printf("baidu 请求完毕")
         close(resp)
      }
   }()

   go func() {

      defer func() {
         recover()
         fmt.Printf("其它 goroutine 已经获得了响应,routine2 退出。")
      }()

      get, err := http.Get("https://www.github.com")
      if err == nil {
         resp <- get
         fmt.Printf("github 请求完毕")
         close(resp)
      }
   }()

   go func() {

      defer func() {
         recover()
         fmt.Printf("其它 goroutine 已经获得了响应,routine3 退出。")
      }()

      get, err := http.Get("https://www.gitee.com")
      if err == nil {
         resp <- get
         fmt.Printf("gitee 请求完毕")
         close(resp)
      }
   }()

   return <-resp

}

第二种思路是:将 resp 设置为容量为 3 的缓冲通道,然后确保所有的 goroutine 都能运行完毕。这里还引入了 sync.WaitGroup ,它可被认为是一个 "多 goroutine 安全" 的信号量。在每个子调用的开头和结尾分别埋下 Add(1)Done() ,表示进入 goroutine 时将信号量 + 1,然后在退出时将信号量 - 1。

对它们的父调用 cdn() 而言,如果信号量非 0 就意味着有些子调用没有完成。这时可以通过调用 Wait() 令父调用阻塞,直到后续的 goroutine 完成并最终将信号量重新置 0 为止。为了不耽误 cdn() 正常返回结果,在这里不妨将它交给另一个 goroutine 独立完成。

func cdn2() *http.Response {

    resp := make(chan *http.Response,3)
    var mu sync.WaitGroup

    go func() {
        // 进入 goroutine 时信号量 +1,退出 goroutine 时信号量 -1。
        mu.Add(1)
        defer mu.Done()

        get, err := http.Get("https://www.baidu.com")
        if err == nil {
            resp <- get
            fmt.Printf("baidu 请求完毕")
        }
    }()

    go func() {

        // 进入 goroutine 时信号量 +1,退出 goroutine 时信号量 -1。
        mu.Add(1)
        defer mu.Done()

        get, err := http.Get("https://www.github.com")
        if err == nil {
            resp <- get
            fmt.Printf("github 请求完毕")
        }
    }()

    go func() {

        // 进入 goroutine 时信号量 +1,退出 goroutine 时信号量 -1。
        mu.Add(1)
        defer mu.Done()

        get, err := http.Get("https://www.gitee.com")
        if err == nil {
            resp <- get
            fmt.Printf("gitee 请求完毕")
        }
    }()


    defer func(){
        // 直接调用 mu.Wait() 会使函数等到所有的响应之后才返回,
        // 对于该函数的预期功能而言,这没有必要。
        go func(){
            mu.Wait()
            fmt.Printf("剩下的请求也处理完毕。")
            close(resp)
        }()
    }()

    return <-resp

}

8.2.6 缓冲通道与同步通道的选择

如何选择这两类通道,以及缓冲通道的长度都会对程序的性能造成影响。一句话概括就是:"缓冲通道" 不总是银弹。

同步通道提供了强制性的同步保障,在一次 "发送" 必须强制对应一次 "接收" 的场合会很有用,比如 HTTP/1.1 协议中的阻塞队列,浏览器必须要等待上一个请求拿到响应之后才会继续发送下一个请求。

而在 "流水线" 式的管道中,"发送" 和 "接收" 通常都是分离的,此时用缓冲通道则更加适合。假定 X 是 Y 的上游 worker,则 X 只管将处理好的数据存取到缓冲通道留给 Y 去处理,X 就可以继续处理它的上游 worker 发送给它的新数据了—— 这对于 Y 也一样。

并且,当 X 和 Y 的处理效率存在一定差异时,缓冲通道为慢的一方争取到了工作时间。比如 X 的生产速度更快,则较长的缓冲通道让 X 不至于因阻塞而 "无事可做",流水线的所有结点都能保持 "持续工作" 的状态。

但如果 X 和 Y 的处理效率存在巨大差异,则缓冲通道在大部分时间要么占满 ( X 的效率 >> Y 的效率 ),要么全空 ( Y 的效率 >> X 的效率 )。此时节点之间就又退化到了同步阻塞的状态。此时的解决方法应当是通过创建更多的 workers 平衡工作上的效率差异,而不是一味地扩增缓冲通道的容量 ( 这只是延缓了阻塞的时机,不能解决根本问题 ) 。

8.2.7 通道关闭

更加正式的做法是:当生产者认为已经没有新消息要发送之后,它会调用 close(...) 主动关闭通道的发送端 —— 这句话的意思是,通道虽然被标记为 closed,但它仍然是可用的 ( 考虑到还在忙碌的消费者 ),只不过后续再向该通道发送消息会引发宕机

ch := make(chan int, 3)

// producer
go func() {
    ch <- 1
    ch <- 2
    ch <- 3
    // 关闭通道。
    close(ch)
}()

同散列表 map 类似,消费者在接收消息时可以获取两个值:第一个值是消息本身,第二个 bool 值代表:"这个通道还有待处理的消息,不管该通道有没有被真的关闭,它还有用"。如果它的值是 false,则表明 "继续从这个通道读取消息没有意义",伴随的则是消息类型的零值。

对于消费者而言:一个被关闭的通道永远是可读的,它会不受阻塞地读取通道的剩余消息,或者是消息的零值和一个 false 。因此如果不加以控制,消费者将陷入死循环。

// consumer
go func() {
    // 不能让它在第一个 goroutine 之前运行,否则它会直接退出
    // 因此在这里设置了少量延迟。
    time.Sleep(10 * time.Millisecond)

    for {
        // 通过 hasMore 变量判断是否还有要处理的消息
        i,hasMore := <-ch
        if !hasMore {break}
        fmt.Print(i,"\n")
    }
}()

只有消息的生产者 ( 或者称 "发送者" ) 应当掌握着关闭通道的权利 —— 在双向通道中,消费者如果 "任性地" 关闭了通道,那会极易导致生产者在发送新消息时意外宕机,而这样的高风险代码却不会在编译期间被检查出来。

在单向通道中,这个约束会更加严格:如果消费者尝试去关闭来自上游的 "只收" 通道,那么这个错误在编译期间就会检查出来。

8.3 Select 多路复用

假定某一个 Job 有多个worker,不同的 worker 使用独立的通道向主 goroutine ( 或者其它某个 listener goroutine ) 进行消息交互。

遗憾的是,由于代码是顺序执行的,因此 listener 无论决定先专注于监听哪一个 worker ,都会导致其它的 worker 被阻塞。在下面的代码块中,由于 listener 首先阻塞式等待 <- ch1 发送的消息 ( 有 5 秒的延迟 ) ,导致 worker2 的消息处理被推迟,这 5 秒钟的时间被 listener 浪费掉了。

ch1 := make(chan int)
ch2 := make(chan int)

// worker1
go func(){
    time.Sleep(5 * time.Second)
    ch1 <- 1
}()

// worker2
go func() {
    ch2 <- 1
}()

//listener
go func(){
    // 阻塞式等待接收 worker1 发来的同步信号
    <- ch1
    fmt.Print("worker1 has done.\n")
    // 在收到 ch1 的信后之后,再等待接收 worker2 发来的同步信号
    <- ch2
    fmt.Print("worker2 has done.\n")
}()

// 阻塞主进程
time.Sleep(10 * time.Second)

如果期望 listener 是 "高响应" 的,直接的办法是创建和 workers 等数量的 listeners 。显然,这大概率会直接创建出一大批闲置并阻塞的 goroutine,也会造成资源的浪费。

一个改进方式是:不妨建立一个哨兵 selector,它专门以轮询的形式监听各个通道,当监听到某个通道有新消息之后再创建新的 goroutine 去处理即可。这样,只需要一个阻塞式的 selector 就能同时监听多个通道的消息,并且只会在所有通道都没有新消息的情况下进入阻塞。

这种思路称之为阻塞式多路复用,曾被用于各大 OS 内核的线程管理。在 Go 语言中,具体的实现是:使用 select ( 它的语法很像 switch ) 语句来注册多个通道,然后包裹一层 for 循环来实现持续轮询。

go func(){
   for {
      // 一次 select 只会执行一个语句。 
      select {
      case <-ch1: go func() {println("worker1 has done.")}()
      case <-ch2: go func() {println("worker2 has done.")}()
      }
   }
}()

但偶尔,select 语句也会面临一些抉择,比如有多个通道都有新消息,这种情况在 "监听" 多个缓冲通道时比较常见。不过,一次 select 调用只会执行一条 case,并且执行哪一个将是随机的。

8.4 关闭 Goroutine

有时,我们需要让多个 goroutine 主动停止自己手头上的任务,比如说客户端在上传文件途中突然取消的情况。子 goroutine 不会自动地随着父 goroutine 的结束而结束 ( 因为这些 goroutine 都是相互平级且独立的。这里的 "父子" 只是代码的调用层级关系的形象描述 ) ,除非这个父 goroutine 是主 goroutine

我们必须设置一个机制,使得父 goroutine 在认为没必要继续执行任务的时候通知它的子 goroutine 停止运行并退出。现在假定有两个子 goroutine 调用,它们各自以秒为单位向控制台输出奇数 / 偶数。现在的需求是:当在控制台按下任意键时,这两个 goroutine 退出,保持主函数仍然运行。

func main(){
    // goroutine1 持续输出偶数
    go func() {
        var i = 0
        for {
            fmt.Printf("[goroutine1]:%v\n",i)
            i = i+2
            time.Sleep(1 * time.Second)
        }
    }()

    // goroutine2 持续输出奇数
    go func() {
        var i = 1
        for {
            fmt.Printf("[goroutine2]:%v\n",i)
            i = i+2
            time.Sleep(1 * time.Second)
        }
    }()

    for {
        fmt.Printf("[main]:still running...\n")
        time.Sleep(1 * time.Second)
    }
}

或许可以建立一个足够长的缓冲通道,主 goroutine 发送 "足够多" 的信号量,令所有接收到信号量的 goroutine 结束工作。那么如何确定当前正在运行的 goroutine 的数量呢?不确定。尤其是对于复杂的系统来说,如果 goroutine 是可以繁殖的 —— 一个 goroutine 在运行时又创建了 ( 多个 ) goroutine ,再或者某些 goroutine 提前执行完并返回了,这都会导致其数量的浮动。

要解决这类问题,不应让父 goroutine 主动通知子 goroutine ,而是应该让子 goroutine 主动检测父 goroutine 的某个信号量。这里利用一个同步通道来实现;

func main() {

	done := make(chan int)

	var cancel = func() bool {
		select {
		// 回想:消费者永远都可以从一个被关闭的通道获取值。
		case <-done:
			return true
		default:
			return false
		}
	}

	// goroutine1 持续输出偶数
	go func() {
		var i = 0
		for {
			if cancel() {
				return
			}
			fmt.Printf("[goroutine1]:%v\n", i)
			i = i + 2
			time.Sleep(1 * time.Second)
		}
	}()

	// goroutine2 持续输出奇数
	go func() {
		var i = 1
		for {
			if cancel() {
				return
			}
			fmt.Printf("[goroutine2]:%v\n", i)
			i = i + 2
			time.Sleep(1 * time.Second)
		}
	}()

	// 开启另一个 goroutine 用于监听输入
	go func() {
		bufio.NewScanner(os.Stdin).Scan()
		close(done)
	}()

	for {
		fmt.Println("[main]:still running...")
		time.Sleep(1 * time.Second)
	}
}

主程序在接收到输入之后关闭了 done 通道。这样做的结果是:cancel() 函数会立刻接收到消息的 nil 值 ( 参考通道关闭节 ) ,并以此作为停止 goroutine 的信号量。主函数的所有子 goroutine 会在每一轮的迭代工作之前调用 cancel() 局部函数进行检查,结果被告知为 true ,因此相继返回退出。

第九章 共享变量

9.1 竞态

假定一个 goroutine X 的内部有三个依次发生的事件:x1,x2,x3 。另一个 goroutine Y 的内部也有三个依次发生的事件,y1,y2,y3。每个事件在各自的 goroutine 能够保证顺序运行,但如果 X 和 Y 并发 运行,则事件 xi 和 yj 之间谁先谁后就无法确定了。假定多个 goroutine 并发地调用了同一个函数,而该函数总能返回正确的结果,那么称该函数是并发安全的。

然而,不是所有的函数都可以确保这一点,其中一个原因就是数据竞态导致的。下面用一个存取款的例子来描述数据竞态如何影响程序运行结果 ( 在数据库教程中,这个例子曾经常用于讲解并发的事务 ):

var balance int = 100

var deposit = func(amount int) {
	balance = balance + amount
}

var show = func() int {return balance}

现在有两个人共享 balance 账户。他们几乎在同一时刻向这个账户中发起存款:

// A
go func() {
    deposit(200)
}()

// B
go func() {
    deposit(100)
}()

time.Sleep(1 * time.Second)
fmt.Print(show())

在串行化的运行环境中,最终账户的余额是 400,这没有任何争议。但是在并发执行的情况下,情况就不一样了:比如 A 在执行到 balance + amount 时,B 恰好通过 deposit 函数更新了余额,此时意味着 A 现在所持有的 balance 是一个过期的数据。用这个过期的数据更新账户余额,会导致 B 的更新会被覆盖 ( 银行凭空从这个账户中赚了 100 元 ) 。

balance = 100
-------------------------------------
Aread = 100    |	
               |    Bread  = 100
               |    Bwrite = 200
Awrite = 300   |
--------------------------------------
balance = 300

这种现象就是数据竞态的一种。它发生的前提条件是 —— 两个或者多个 goroutine 共享一个变量,并且至少有一方进行了写入操作。不过这个例子太轻量级了,我们很难看到程序反馈预期外的结果。

另一个例子会引发明显的现象:一个 "薛定谔" 的下标访问。两个 goroutine 同时为一个 x 赋值,而 x 的长度有可能是 10,有可能是 100 ( 取决于哪个 goroutine 后执行 ) 。一旦 x 的长度是 10,那么这个主函数最终就会因下标越界而报错。

var x []int

go func() {
    x = make([]int,100)
}()

go func() {
    x = make([]int,10)
}()

time.Sleep(100 *time.Millisecond)
fmt.Print(x[99])

有三种方式可以消除数据竞态 —— 第一种,如果数据是不可变的,那么它一定是并发安全的。

var x []int
const (
    LENGTH = 100
)

// 两个 goroutine 总是基于常量创建切片。
go func() {
    x = make([]int,LENGTH)
}()

go func() {
    x = make([]int,LENGTH)
}()

time.Sleep(100 *time.Millisecond)
fmt.Print(x[99])

但在一定会执行更新操作的业务中 ( 比如刚才银行账户的例子 ) ,这种方法不现实。第二种方法是:避免在多个 goroutine 下对变量进行改动。

回到银行账户的例子当中,其它的 goroutine 线程只能提交修改请求,这些请求被发送给一个 balance 的代理 goroutine ,只有它能够对 balance 进行修改,或者回显。换句话说, balance 本身将对 A 和 B 不可见,他们被限制以同步通道的形式和代理交互数据。这对应了开篇 Go 语言的那句箴言。

request := make(chan int)
response := make(chan int)

var deposit = func(amount int) {
    request <- amount
}

var show = func() int { return <-response }

// A
go func() {
    deposit(200)
}()

// B
go func() {
    deposit(100)
}()

// Proxy
go func(){
    // Read from MySQL
    var balance = 100

    for{
        select {
            case a := <-request: balance += a
            case response <- balance:
        }
    }
}()

time.Sleep(1 * time.Second)
fmt.Print(show())

有些变量无法在一整个 goroutine 之内做出限制,这时可以通过 通道 将这个变量从流水线的上游逐步传递到下游。在流水线的单个节点内部,可以对这个变量做任意的串行操作,但是一旦这个变量发送出去,该节点就不允许再修改变量了。说得再直白些就是:变量首先传递到某个节点串行使用,然后再传递到下一个节点串行使用。这个变量共享的方式称之为串行受限

syncCh1 := make(chan int)
syncCh2 := make(chan int)
syncCh3 := make(chan int)

go func(in <-chan int,out chan <- int){
   i := <-in
   out <- i + 3
}(syncCh1,syncCh2)

go func(in <-chan int,out chan <- int){
   i := <-in
   out <- i * 2
}(syncCh2,syncCh3)


syncCh1 <- 1
// 串行受限方式地计算 ( x + 3 ) * 2。
fmt.Print(<-syncCh3)

第三种方式则是最熟悉的互斥机制。

9.2 互斥锁 sync.Mutex

即便不借助任何额外地工具,我们也可以使用容量为 1 的缓冲通道构建一个互斥锁。这个通道表征了一个二进制信号量,有消息表示上锁,没有消息表示无锁。

var (
    mu = make(chan int,1)
    balance = 100
)

var deposit = func(amount int) {
    balance = balance + amount
}

var show = func() int{return balance}

// A
go func() {
    // 加锁
    mu <- 1

    // 以下是临界代码
    deposit(100)

    // 放锁
    <-mu
}()

// B
go func() {
    mu <- 1

    // 以下是同步代码
    deposit(200)

    <-mu
}()

互斥锁的用途十分广泛,因此 Go 直接提供了 sync.MutexLock() 方法即加锁,Unlock 方法即放锁:

var (
    mu sync.Mutex
    balance = 100
)

var deposit = func(amount int) {
    balance = balance + amount
}

var show = func() int{return balance}

// A
go func() {
    mu.Lock()
    // 以下是同步代码
    deposit(100)

    mu.Unlock()
}()

// B
go func() {
    mu.Lock()
    // 以下是同步代码
    deposit(200)

    mu.Unlock()
}()

time.Sleep(1 * time.Second)
fmt.Print(show())

所有夹杂在 Lock()Unlock() 之间的代码块称之为临界区。在其它 goroutine 通过 Lock() 加锁之后,其它 goroutine 在执行到 Lock() 会陷入阻塞状态 ( 这个原理是Go 提供的这把锁是不可再入的,即不能给已经上锁的锁头再加锁 ),直到它抢先获得互斥锁为止。因此,对于上述的例子而言,这保证了同一时间 A 和 B 只可能有一个人调用 deposit() 函数。

另外,在一个 goroutine 内,加锁和放锁操作必须是对应的,否则会导致其它等待释放锁的 goroutine 陷入死等的状态。这时,不如将放锁的动作添加到延迟调用栈中,这样函数将总是可以保证在退出之后 ( 无论是正常退出,还是因为内部发生错误而短路退出的 ) 能够释放锁资源。

mu.Lock()
defer mu.Unlock()

一个由互斥锁保护的调用是并发安全的,而代价是牺牲少许性能。

9.3 共享锁 sync.RWMutex

假定银行发现大部分客户对账户的操作是读取而非写入,则可以考虑将一些事务的读操作设置成共享锁 ( 读写锁 ) 而非互斥锁。这样,如果有两个 goroutine 竞争同一个资源,但是它们都没有对这个竞争资源进行写操作,则可以共享它。换句话说,只有读-读锁可共存。

var (
    mu sync.RWMutex
    balance = 100
)
// ... 对于写入操作,调用的仍然是 Lock 和 Unlock 方法,因此在这里省略重复代码。
var show = func() int{
    mu.RLock()
    defer mu.RUnlock()
    return balance
}

因此,共享锁 ( 即读锁) 保护的临界区不应当存在包括写入数据在内的副作用。另外,共享锁内部机制比互斥锁更加复杂,因此 sync.RWMutex 只有在 goroutines 激烈地竞争读操作时才会比互斥锁更加有优势,否则,它的效率比互斥锁低。

9.4 延迟初始化 sync.Once

由于 if 或者 switch 这类选择分支的存在,程序不保证所有的变量在运行时都会用到。尤其是某些资源的初始化需要比较大的代价时,将它声明为一个 "懒汉式" 单例更为明智一些。

var resp map[string]string

func loadResource() {
	fmt.Printf("resp 加载开始...\n")
	if resp == nil {
		resp = map[string]string{
			"banner":     "/banner.jpg",
			"body":       "/body.jpg",
			"background": "/background.jpg",
		}
	}
}

func main(){
    loadResource()
    // 主 goroutine 是串行运行的,因此我们总是能得到正确的结果。
    fmt.Printf("resp = [%v]", resp["body"])
}

这段代码不是 "goroutine 安全" ( 在其它语言中,这个现象称之为 "线程安全" ) 的。现在假定有两个 goroutine 分别执行初始化和访问工作,且其中一个 goroutine 早在 resp 被初始化之前就访问它,那么会得到一个空值,进而可能引发重复的初始化动作:

// 一个 goroutine 正进行初始化。
go loadResource()

// 令一个 goroutine 正尝试读。
// 如果它没有观测到 resp 被初始化完成,则它会自行执行 loadResource() 函数。
go func() {
    s,ok := resp["body"]
    if !ok {
        loadResource()
        fmt.Printf("resp = [%v]",s)
    } 
    else {fmt.Printf("resp = [%v]",s)}
}()

比如在上述代码中,加载函数 loadResource 有可能会被调用多次,这种情况下会造成系统资源浪费。控制台有可能会打印:

resp 加载开始...
resp 加载开始...

一个有效的方法是使用互斥锁 sync.Mutex

go func() {
    loadMu.Lock()
    loadResource()
    loadMu.Unlock()
}()

go func() {
    loadMu.Lock()
    s,ok := resp["body"]
    loadMu.Unlock()

    if !ok {
        loadMu.Lock()
        loadResource()
        loadMu.Unlock()
        fmt.Printf("resp = [%v]",s)
    } else {fmt.Printf("resp = [%v]",s)}
}()

通常,对某个昂贵资源的初始化工作只需要一次,后续仅仅是对该资源的调用。因此,这里不妨使用共享锁 sync.RWMutex 来代替互斥锁。

var loadMu sync.RWMutex

go func() {
    loadMu.Lock()
    loadResource()
    loadMu.Unlock()
}()

go func() {
    loadMu.RLock()
    s,ok := resp["body"]
    loadMu.RUnlock()

    if !ok {
        loadMu.Lock()
        loadResource()
        loadMu.Unlock()
        fmt.Printf("resp = [%v]",s)
    } else {fmt.Printf("resp = [%v]",s)}
}()

Go 提供了一个更方便的工具来帮助实现 "goroutine 安全" 的懒汉式加载。下面的代码块将 loadResource 迁移到了一个 sync.Once 变量内:

var resourceOnce sync.Once

// worker1 
go func() {
    resourceOnce.Do(loadResource)
}()

// worker2
go func() {
    s,ok := resp["body"]
    if !ok {
        // sync.Once 保证 loadResource 全局只会被调用一次。
        resourceOnce.Do(loadResource)
        fmt.Printf("resp = [%v]",s)
    } else {fmt.Printf("resp = [%v]",s)}
}()

其函数调用 Do(loadResource) 总是能够保证在上下文中 loadResource 函数只会被调用一次。如果后续有其它 goroutine "误触" 了此加载函数,则这个 Do 将是一个空调用。

9.5 竞态检测器

一个残酷的现实是,无论是多么小心翼翼地操纵多个 goroutine ,在并发编程的环境中数据竞态难以避免。Go 提供了便捷的工具来帮助程序员对测试并记录程序在实际运行时出现的数据竞态。如果需要使用它,则在 go 命令后面补充上一个 -race 参数即可。在 GoLand IDE 中,可以通过 Edit Configuration -> Go tool arguments 进行设置。

这个参数会命令 Go 编译器基于我们原有的代码构建一个附带竞态检测器的版本 ( 因此编译的时间可能要更长一些,但相比人工分析数据竞态而言,这点成本可以欣然接受 ) 。竞态检测器随着程序的运行检测事件流,并报告发生冲突的案例,下面是笔者在本机通过竞态检测器分析之前的案例程序并打印出的报告:

==================
WARNING: DATA RACE
Write at 0x000000443688 by goroutine 7:
  main.loadResource()
      C:/Users/liJunhu/go/src/awesomeProject/main/runFirst.go:13 +0x204
  sync.(*Once).doSlow()
      C:/Go/src/sync/once.go:66 +0x10a
  sync.(*Once).Do()
      C:/Go/src/sync/once.go:57 +0x72
  main.main.func1()
      C:/Users/liJunhu/go/src/awesomeProject/main/runFirst.go:27 +0x4b

Previous read at 0x000000443688 by goroutine 8:
  main.main.func2()
      C:/Users/liJunhu/go/src/awesomeProject/main/runFirst.go:31 +0x45

Goroutine 7 (running) created at:
  main.main()
      C:/Users/liJunhu/go/src/awesomeProject/main/runFirst.go:26 +0x94

Goroutine 8 (running) created at:
  main.main()
      C:/Users/liJunhu/go/src/awesomeProject/main/runFirst.go:30 +0xb6
==================

这段报告的大体意思是说:worker1 ( 即 Goroutine 7 ) 尝试初始化资源 resp ( 地址 0x000000443688 ) 之前, worker2 ( Goroutine 8 ) 正准备调用 resp["body"] 来试图获取一个值,由此引发了这两个 routine 之间的数据竞争。

不过需要注意的是,它只记录本次运行时发生的竞态,不确保这个竞态在将来仍然会发生。

9.6 Goroutine & 线程

首先,两者在数量和体量上可以存在巨大的差别。操作系统预留的用户线程占用固定大小的栈内存 ( 一般是 2 MB ) ,它用于保存函数调用期间所产出的局部变量。相比之下,一个 goroutine 占用非固定大小的栈内存 ( 用途和用户线程的占内存类似 ),并且在程序刚运行时它占用的内存通常都非常小 ( 或许只有 2KB ) 。随着程序的不断深入,一个 goroutine 所占用的占内存可以按需增加 / 减少,甚至说一个 goroutine 就能够占据 1 GB 的空间 ( 这比常规的线程还要庞大 )。

用户线程由操作系统内核来调度。每过几毫秒,一个硬件时钟便会向 CPU 发送中断信号,CPU 通过调用名为调度器的内核函数 ( 这会使 CPU 从用户态陷入内核态 ) 实现上下文切换:即保存上一个线程的运行状态,并恢复当前线程的运行状态,其整个过程都需要一定的时间成本。假定程序频繁进行线程切换,那么这个时间成本的累计将是可观的。

Go 语言使用自己的调度器实现 goroutine 切换,它建立在 G-P-M 模型之上。简单来说,该调度器使用 m : n 技术将 m 个 goroutine 映射到了 n 个操作系统的用户线程。它的工作机制和 CPU 的调度方式大体类似,但是它只需要关心 Go 程序如何调度 goroutine ,而不需要关注如何操作用户线程。在此期间,将 goroutine 映射到用户线程的逻辑则交给模型中的 "P" —— Processor,逻辑处理器去管理,它充当了 "中介人" 的身份。

Go 调度器也不依赖硬件时钟来实现切换,而是由 Go 自身的架构来触发。和线程切换相比,它避免了 CPU 直接陷入内核态,因此切换 goroutine 的代价要比切换线程的代价要小。

这里要提要一个 GOMAXPROCS 环境变量,它代表了 "m : n" 当中的 n 。通常,这个数值等于机器中 CPU 的数量。比如对于 8 核机器而言,一个 goroutine 会被分配到 8 个用户线程的其中一个。假定一个用户线程中的某一个 goroutine 因为调用 time.Sleep 函数或者通道通信而阻塞了,那么该用户线程的其它活跃的 goroutine 可以挪动到另一个可用的用户线程继续执行。

但如果一个 goroutine 的执行涉及系统调用,或者依赖由其它语言实现的函数,这就需要另分配用户线程来执行,但这不会计算在 GOMAXPROCS 的数目内。Go 语言鼓励简单的编程风格,因此它特意没有为 goroutine 留下可被寻找的标识:一个函数的运行结果应当只由它的参数列表来决定,而不是 "谁来运行它"。