Go的内存模型规定了Go语言中,协程间访问共享内存可见性保证的机制,即在满足内存模型条件下,一个协程中能够读取到其它协程中更新的数据。
内存模型出现的背景是多核CPU缓存一致性问题,因此不只是Go语言有内存模型的设计,Java、JavaScript等语言也有类似设计。
我们知道协程间可见性问题,或者说线程间可见性问题,根源在于现代计算机使用了多核CPU技术,具备并行执行程序的能力。CPU的运行速度与内存、磁盘访问速度差距甚远,因此会通过多级缓存技术,来协调CPU与存储之间速度的差异,越靠近CPU的地方,访问速度越快。在CPU硬件设备上,一般设置了多级缓存,例如如下这种模型:
越靠近CPU的地方,缓存空间越小,但访问速度越快,价格也越贵。
缓存工作原理基于“局部性原理”,也就是“时间局部性”和“空间局部性”,如果某个数据被访问,那么可能很快会再次被访问,与它相邻的数据也可能会被访问,
缓存在提升性能的同时也会有一致性的问题,在多核CPU中,内存的数据会加载到多级缓存中,形成多个数据副本,如果某个核心上正在执行的线程修改了共享数据后,其他核心上的线程无法立即感知到,出现了缓存不一致的问题。因此需要一种机制保证,如果某个核心上进行了共享数据的修改,需要立即刷新数据到内存中,并通知其他线程相应数据已经过期失效,在其他线程读取该数据时,需要从内存中重新加载。也就是缓存一致性协议,常见的缓存一致性协议有多种,最经典的是MESI协议。
回到Go的内存模型,Go程序中多协程并发访问共享数据时,需要有一种同步机制,Go的标准包依据内存模型设计,提供了多种同步组件,例如channel、mutex与atomic等。
在Go中有数据竞争(data race)的概念,当一个协程在访问共享数据时,另一个协程在修改该数据,就会出现数据竞争。
在Go的内存模型设计中,对于数据竞争的结果做了一定的限制,例如异常的数据可能只限定在某几个值之间,取决于后执行的协程做出修改并在某个特定时间刷新到内存,覆盖之前其它协程修改的值,这点与Java、JavaScript等语言的内存模型设计类似,但是与C和C++的设计不同,它们在出现数据竞争后,编译器的处理结果是未知的。
在Go语言中,数据竞争引起的错误是有限的,这是为了更方便进行问题的排查,Go始终认为数据竞争是一种错误,需要通过工具来诊断并发现它们,进而通过修正代码逻辑来消除数据竞争。
内存操作(memory operation)组成了协程执行(goroutine execution),进而组成了程序执行(program execution),而程序执行需要遵从内存模型描述的以下三点要求。
- 单个协程中的内存操作,无论是读操作还是写操作,对于同一份内存空间的访问,需要以正确的顺序执行,内存操作之间遵循“sequenced before”顺序,这点是程序正确运行需要自身保证的;
- 多个协程间的内存操作,对于访问共享内存,如果需要保证确定的先后顺序,就需要使用同步机制。例如,如果同步读类型的内存操作,要能观察到同步写类型的内存操作修改后的最新值,就要满足“synchronized before”顺序的要求。
- 对于非同步的普通读类型的内存操作,如果所读取的共享数据,在此之前也被其他协程的写类型的内存操作所访问,为了不产生数据竞争,即实现“data-race-free”的程序设计,需要满足“happens before”顺序,例如通过与同协程的其它“同步的内存操作”建立“sequenced before”顺序,并依赖其与其它协程的“同步内存操作”建立“synchronized before”顺序,然后再依赖其它协程的“sequenced before”顺序。这样一个协程的非同步读操作与另一个协程访问相同内存空间的写操作,建立了一种“happens before”顺序。