Golang 并发编程

Golang 通过编译器运行时(Runtime),从语言上原生支持了并发编程。

并发与并行

学习 go 并发编程之前,我们需要弄清并发、并行的概念。

由于 CPU 同一时间只能执行一个进程/线程,在下文的概念中,我们把进程/线程统称为任务。不同的场景下,任务所指的可能是进程,也可能是线程。

并发(Concurrency)

并发是指计算机在同一时间段内执行多个任务。

并发的概念比较宽泛,它单纯是指计算机能够同时执行多个任务。比如我们当前是一个单核的 CPU,但是我们有5个任务,计算机会通过某种算法将 CPU 资源合理的分配给多个任务,从用户角度来看的话就是多个任务在同时执行。前面说的的算法比如“时间片轮转”。 单核并发

并行(Parallelism)

并行是指在同一时刻执行多个任务。 当我们有多个核心的 CPU 的时候,我们同时执行两个任务,就不需要通过“时间片轮转”的方式让多个任务交替执行了,一个 CPU 负责一个任务,同一时刻,多个任务同时执行,这就是并行。

并行

并发+并行

上面的并行图中所展示的任务执行机制,是一种理想化的情况,即执行任务的数量等于 CPU 的核心数量。但实际的场景中,任务数是远大于 CPU 的核心数量的。比如我的电脑是8核的,但是我开机就要启动几十个任务,这个时候就会出现并发和并行都存在的情况。 并发和并行

并行和并发的区别

并发和并行的根本区别是任务是否同时执行。
并发不是并行。并行是让不同的代码片段同时在不同的物理处理器上执行。并行的关键是同时做很多事情,而并发是指同时管理很多事情,这些事情可能只做了一半就被暂停去做别的事情。
在很多情况下,并发的效果比并行好,因为操作系统和硬件的总资源一般很少,但能支持系统同时做很多事情。这种“使用较少的资源做更多的事情”的哲学,也是指导Go语言设计的哲学。

goroutine

在了解 goroutine 之前我们先了解一下什么是协程(coroutine)。

协程(Coroutine)

这里我们不再多赘述进程、线程的关系,我们来看下协程。协程是其他编程语言中的一种叫法,但并不是所有编程语言都支持 coroutine。 所以协程是什么?

  1. 轻量级的“线程”:作用和线程差不多,都是并发执行一些任务的。
  2. 非抢占式多任务处理,即由协程主动交出控制权。这里需要了解一下抢占式非抢占式的区别:
    • 抢占式:以线程为例,线程在任何时候都可能被操作系统进行切换,所以线程就叫做抢占式任务处理,即线程没有控制权,任务即使做到一半,哪怕没有做完,也会被操作系统给抢占了,然后切换到其他任务去。
    • 非抢占式:非抢占式的代表就是协程了,协程在任务做到一半的时候可以主动的交出任务的控制权,控制权是由协程内部决定,也正是因为这一特性,协程才是轻量级的。需要注意的是,当一个协程不主动交出控制权的时候,可能会造成死锁,也就是说控制权会一直在这个协程内部,程序将长时间等待,无法跳出。
  3. 编译器/解释器/虚拟机层面的多任务,非操作系统层面的,操作系统层面的多任务就只有进程/线程。
  4. 多个协程可能在一个或多个线程上运行,大多数情况下由调度器决定。
  5. 子程序(函数调用,比如 func a() {})是协程的一个特例。

这里需要解释一下第5点,为什么子程序是协程的一个特例的。
我们来看一下普通函数和协程的对比: 普通函数: 在一个线程内,有一个 mian 函数,main 函数调用函数 work, 然后 work 开始执行,work 执行结束后会把控制权交给 main 函数,然后 main 函数会执行后面的函数等。 普通函数

协程: 协程中 main 函数和 work 函数之间是有个双向的通道(下图中是双箭头),彼此通过通道来进行通信,且两者的控制权也可以双向的交换。那么协程运行在哪里呢?可能是运行在同一个线程,也可以分别运行在不同的线程。协程具体是怎么被分配的,一般作为应用层的使用者来说,我们是不用关心的,这些完全是由调度器来完成操作的。 协程

关于协程第2点控制权的部分,后面我们讲到 goroutine 的时候学习一下如何“迫使”协程主动交出控制权的方式,这里暂时就先不详细说明了。

go 语言的协程(goroutine)

goroutine 其实是一种协程,或者说和协程比较像。

在上文中我们了解了通用编程语言中的协程概念后,终于轮到今天的主角 goroutine 了。我们先来看一下 goroutine 模型。 看图比较容易理解,首先是 go 程序启动一个进程,同时启动一个调度器,在这个调度器之上会分配 goroutine 的调度,也就是上面提到的,多个 goroutine 可能分配在一个线程中,也可能被分配到不同的线程中。
goroutine

goroutine 的定义

  • 通常给函数加上 go 关键字,就能交给调度器调用:
go 函数名(参数列表) // 具名函数形式
go func(参数列表) { // 匿名函数形式
    函数体
}(调用参数列表)
  • 定义时无需区分函数是否异步,python 中协程的定义需要用到 async 关键字
  • 调度器会在合适的时机切换 goroutine,即使 goroutine 是非抢占式的,但是操作权并不完全在 goroutine,这也是 goroutine 和传统协程的一点区别

使用 goroutine

package main

import (
	"fmt"
	"time"
)

func main() {
	for i := 0; i < 10; i++ {
		go func(i int) {
			fmt.Print("i:", i, ", ")
		}(i)
	}
}

Go 程序一般从 main 包的 main 函数开始,在程序启动的时候,Go 程序就会为 main 函数创建一个默认的 goroutine,需要注意的是使用 go 关键字创建 goroutine时,被调用的函数的返回值会被忽略。 执行上面的代码输出结果是:

/private/var/folders/rh/6jh584kn2jb7fbp2ymcjw9800000gn/T/GoLand/___go_build_go_leaning_go_routine

Process finished with the exit code 0

 

很奇怪,明明fmt.Print 了,但第2行什么都没有打印,接着程序就直接退出了。原因是因为程序中 main 函数 和 其他 goroutine 是并发执行的,for 循环执行完之后就直接跳出循环,main 就退出了,代码中的 Print goruntine 还来不及打印就被程序干掉了。 如何看到结果呢?main 程序慢点退出就可以了,稍微加点料:

func main() {
	for i := 0; i < 10; i++ {
		go func(i int) {
			fmt.Print("i:", i, ", ")
		}(i)
	}
	time.Sleep(time.Millisecond) // 延迟 main 函数退出
}
// result:
// i:0, i:2, i:5, i:4, i:6, i:7, i:8, i:9, i:3, i:1, 
// Process finished with the exit code 0

再稍微改动一下代码,让 goroutine 无法主动交出控制权:

func main() {
	var arr [10]int
	for i := 0; i < 10; i++ {
		go func(i int) {
			for {
				arr[i]++
			}
		}(i)
	}
	time.Sleep(time.Minute) // 休眠1分钟
	fmt.Print("arr: ", arr)
}

执行修改后的代码发现,IDE 显示程序一直处于运行状态,我们再来通过 top 命令查看一下电脑 CPU 使用情况:

由于我电脑的 CPU 是8核的,如果 CPU 打满的话是 占用率应该是800%,上图能看到的是 goroutine 已经占用了716.%。休眠结束后可以看一下具体输出:

arr: [10582140406 10463247362 10747009051 10642989545 10505259520 10456203629 10500957117 10661942229 10440913209 10357335909] 

goroutine 交出控制权

上面说协程(coroutine)的时候讲到,非抢占式任务可以主动交出控制权,我们看下 goroutine 是如何交出控制权的方式:
1)I/O 操作交出控制权: 其实 I/O 操作我们上面已经看到过了,就是 fmt.Print() 等...

func main() {
	for i := 0; i < 10; i++ {
		go func(i int) {
			fmt.Print("i:", i, ", ")
		}(i)
	}
	time.Sleep(time.Millisecond)
}

2)runtime.Gosched() :

func main() {
	for i := 0; i < 10; i++ {
		go func(i int) {
			fmt.Print("i:", i, ", ")
			runtime.Gosched()
		}(i)
	}
	time.Sleep(time.Millisecond)
}

3)select 操作,大致结构如下:

select {
	case <- chan1: // 如果 chan1 成功读到数据就执行该 case 语句处理
	case chan2 <- 1 // 如果成功向 chan2 写入数据,就执行该 case 语句
	default: // 如果以上都没成功就执行该语句
}

需要注意的是,每个 case 语句都必须是面向 channel 操作的。
4)channel:

func getData(values chan int) {
	value := rand.Intn(20)
	values <- value
}
func main() {
	values := make(chan int)
	go getData(values)
	value := <-values
	fmt.Println("Channel value: ", value)
}

5)等待锁,即传统模式的锁同步机制: 可以通过 sync.Mutex 实现,这里就不多赘述了。
6)函数调用(有时会): 我个人理解是,如 time.sleep() 的 Sleep 函数,func Sleep(){} 的官方 API 解释是: Sleep pauses the current goroutine for at least the duration d 即 sleep 当前的 goroutine d duration 时间。
7)其他...

goroutine 闭包陷阱

还是先来看一段代码:

func main() {  
   var arr [10]int  
   for i := 0; i < 10; i++ {  
      go func() {  
         for {  
            arr[i]++  // look!
            runtime.Gosched()  
         }  
      }()  
   }  
   time.Sleep(time.Millisecond)  
   fmt.Print("arr:", arr)  
}

这段代码只是把参数列表和调用参数列表给移除了,变量 i 直接使用了 for 循环定义的 i ,这时候再运行代码,控制台就会报错,程序 panic 了:

panic: runtime error: index out of range [10] with length 10

如果不明白这个问题的话,go 语言提供了我们一个命令去排查问题, -race 命令就是我们去检测数据的冲突:

go run -race routine1.go

使用该命令运行程序之后会打印很多日志,我们挑重点的看:

==================
WARNING: DATA RACE
Read at 0x00c00013c018 by goroutine 7:
...
Previous write at 0x00c00013c018 by main goroutine:
...
WARNING: DATA RACE
Read at 0x00c00013e010 by goroutine 7:
...
Previous write at 0x00c00013e010 by goroutine 8:

重点看一下 WARNING: DATA RACE,这里的 RACE 指的就是竞态(race condition)。
再看下上面的日志,Read at 0x00c00013c018 by goroutine 7 这是个通过 goroutine 7 进行的读操作,Previous write at 0x00c00013c018 by main goroutine 这是个通过 main goroutine 进行的写操作。这两个操作都指向了同一个内存地址 0x00c00013c018,这个内存地址代表了什么呢?答案是代表了变量i 。 造成上面竞态的根本原因就是闭包陷阱,即 for 循环执行完之后 i 被设置为10,最终每个 goroutine 操作的都是 arr[10],所以就会出错。 解决方式的话就可以通过参数列表和调用参数列表每次拷贝一个新 i 的值给 goroutine 就可以了。 我们加上参数列表和调用参数列表再运行一下程序,结果:

arr:[582 592 628 647 489 568 490 618 529 400]

再通过 -race 命令跑一下:

==================
WARNING: DATA RACE
Read at 0x00c00013e000 by main goroutine:
  runtime.racereadrange()
      <autogenerated>:1 +0x1b

Previous write at 0x00c00013e000 by goroutine 7:
main.main.func1()
/Users/li2/Code/go_learning/go_routine/routine1.go:14 +0x64
...
arr:[2695 1962 1651 1580 1519 1604 1275 1477 1484 1506]Found 1 data race(s)
exit status 66

奇怪了,还是有 data race warning,这里错误有两个,一个是 main goroutine 的读,一个是代码中第 14行的写,这两行代码分别是:

// main goroutine
fmt.Print("arr:", arr)
// 14 行的
arr[i]++

也就是说程序一边在 fmt.Print(arr),又一边在并发执行 arr[i]++,这个问题要怎么解决呢? 答案是通过 channel 来解决,这篇文章里就不过多做介绍了。

以上。