并发是一个有趣的词,同时我们还可能听过其他词,如:“并行”,“串行”,“线程”,“多线程”等。在计算机科学中,并发是一个宽泛的话题,涉及到许多的主题。很有必要弄清楚他们的定义和区别。

1、基础概念

1.1、并发和并行

并发 (Concurrent) : 多个事情在同时发生的过程,这个“同时”具体可以是一个时间段,也就是在同一时间段内多个事情正在进行。

并行 (Parallel) : 在同一时刻,多个事情同时在进行。

具体到操作系统中,当有多个线程在操作时,如果系统只有一个CPU,则它根本不可能真正同时进行一个以上的线程,它只能把CPU运行时间划分成若干个时间段,再将时间 段分配给各个线程执行,在一个时间段的线程代码运行时,其它线程处于挂起状。这种方式我们称之为并发(Concurrent)。

Concurrent

当系统有一个以上CPU时,则线程的操作有可能非并发。当一个CPU执行一个线程时,另一个CPU可以执行另一个线程,两个线程互不抢占CPU资源,可以同时进行,这种方式我们称之为并行(Parallel)。

Parallel

1.2、并发并行区别

引用Ealang之父Joe Aramstrong 给5岁小孩解释并发和并行间区别

difference

并发是两个队列交替使用一台咖啡机,并行是两个队列同时使用两台咖啡机。

并发的多个任务间是相互抢占资源的,而并行的多个任务是不相互抢占资源的。

1.3、串行

  • 指串行通信(Serial communication)是指在计算机总线或其他数据通道上,每次传输一个位元数据,并连续进行以上单次过程的通信方式。与之对应的是并行通信,它在串行端口上通过一次同时传输若干位元数据的方式进行通信
  • 和并行相对应,做某个事情时,一个步骤一个步骤的进行处理

1.4、数据竞争(data race)和竞争条件(race condition)

当计算机程序中有多个同时执行的代码路径时,就会出现竞争条件(data condition),如果多个代码执行路径花费的时间和预期不同,它们可能以与预期不同的顺序完成,这可能由于意外的行为导致软件bug。

数据竞争是一种竞争条件。数据竞争是各种形式内存模型的重要组成部分。C11C++11 标准中定义的内存模型指定包含数据竞争的 C 或 C++ 程序具有未定义的行为(undefined behavior )。

Go在1.19版本,修改了内存模型的定义,使它的内存模型同其他语言,如:C、C++、Java、JavaScript、Rust和Swift等保持一致。

竞争条件可能难以重现和调试,因为最终结果是不确定的,并且取决于干扰线程之间的相对时间。在调试模式下运行、添加额外的日志记录或者附加调试器时, 这种性质的问题可能因此而消失,因此最好通过仔细的软件设计来避免竞争条件。

数据竞争(data race) 的精准定义特定于所使用的并发模型,但通常情况指的是一种情况,即一个线程中的内存操作可能尝试访问内存位置,同时另一个线程中的内存操作正在写入改内存位置,这种情况下这是危险的。 这意味着数据竞争和竞争条件不同,因为即使在没有数据竞争的程序中,由于时序,也可能具有不确定性,如,在所有内存访问都使用原子操作的程序中。

许多平台中下面的操作是危险的,如果两个线程同时写入一个内存位置,这个内存地址可能保存一个值,该值是每个线程尝试写入值的bit位的任意且无意义的组合; 如果生成的值都不是两个线程试图写入的值,则可能导致内存损坏。同样的,一个线程从某个位置读取,而另一个线程正在写入该位置,则有可能返回一个值,这个值可能是写入值的bits位和已有值的bits位的任意且无意义的组合值。

2、Go中的数据竞争和竞争条件

Go语言中的每一个并发执行单元叫做协程(goroutine),一个程序启动时,其主函数即在一个单独的goroutine中运行,也叫它main goroutine。goroutine是轻量级的,一个goroutine会以一个很小的栈开始生命周期,一般只需要2KB,和操作OS线程一样,会保存其活跃或挂起的函数调用的本地变量。 不一样的地方是,一个goroutine的栈大小不固定,根据需要动态伸缩。

Go支持两种并发模型,一种是CSP(Communicating Sequential Process,通信顺序进程)模型,另一种是内存同步访问。

Go的内存模型中也有上述的数据竞争和竞争条件。

2.1、竞争条件(race condition)

当两个或多个操作必须按照正确的顺序执行,而程序并未保证这个顺序,就会发生竞争条件。

2.2、数据竞争(data race)

当有两个或者多个 goroutine 同时访问一个变量,其中至少有一个访问是写入时,就会发生数据竞争(data race)。

2.3、数据竞争检测器

如下面是一个可能导致崩溃和内存损坏的数据竞争示例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
func main() {
	c := make(chan bool)
	m := make(map[string]string)
	go func() {
		m["1"] = "a" // First conflicting access.
		c <- true
	}()
	m["2"] = "b" // Second conflicting access.
	<-c
	for k, v := range m {
		fmt.Println(k, v)
	}
}

为了帮助诊断这类bug,Go内置了一个数据竞争检测器,可以通过添加-race 标志到go command中来使用它:

1
2
3
4
$ go test -race mypkg
$ go run -race mysrc.go
$ go build -race mycmd
$ go install -race mypkg

当竞争检测器在程序中发现数据竞争时,它会打印一份报告,其中包含冲突访问的堆栈跟踪,以及创建关联goroutine的堆栈,如:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
$ go run --race datarace/main.go
==================
WARNING: DATA RACE
Write at 0x00c000112180 by goroutine 7:
  runtime.mapassign_faststr()
      src/runtime/map_faststr.go:203 +0x0
  main.main.func1()
      atmoic-pointer/datarace/main.go:9 +0x50

Previous write at 0x00c000112180 by main goroutine:
  runtime.mapassign_faststr()
      src/runtime/map_faststr.go:203 +0x0
  main.main()
      atmoic-pointer/datarace/main.go:12 +0x132

Goroutine 7 (running) created at:
  main.main()
      atmoic-pointer/datarace/main.go:8 +0x115
==================
1 a
2 b
Found 1 data race(s)
exit status 66

2.4、处理数据竞争的建议

  • 如果多个goroutine并发访问修改数据时,必须保证各操作序列化执行(串行执行)
  • 为了序列化执行,可以使用channel或者其他同步原语(如sync 和 sync/atomic包里提供的)来保护被共享的数据

sync等包提供了Mutex(互斥锁)、RWMutext(读写锁)、Once、WaitGroup、Atomic Values 等传统锁机制,但Go语言更推荐使用channel进行同步通信,“不要通过共享内存来通信,相反,通过通信来共享内存”

Do not communicate by sharing memory; instead, share memory by communicating. - effective go

追求简洁,尽量使用channel,并且认为goroutine是低成本的。

参考资源