searchusermenu
  • 发布文章
  • 消息中心
点赞
收藏
评论
分享
原创

Golang 内存模型详解

2023-05-29 05:46:03
52
0

写在前面的话

数据竞态:当存在两个或以上的goroutine,对一个数据,同时进行读写操作时(非原子操作),我们称之为该数据存在竟态。在竟态条件下,读者可能读到非一致性的数据:比如一个结构体数据,goroutine1 正在读v1版的数据;此时goroutine2 进行数据更新到v2;导致goroutine1读取到的数据部分是v1版的数据,部分是v2版的数据。

当多个goroutine操作存在竟态的数据时,必须将并发操作进行顺序化,避免出现数据不一致。在golang中可以实现操作顺序化的方法有:基于通道(channel)通信,使用同步原语(如sync包中的Mutex、RWMutex等),使用原子操作(sync/atomic)等。

另一方面,Golang内存模型,定义了一些限制条件,当程序遵循这些限制条件时,就可以实现数据免竟态。这些限制条件被称之为“Hanpens Before”规则。我们将在下一章重点说明。

警告:

理解和掌握golang 的 Hanpens Before规则,有助于我们了解哪些情况下,数据的可见性是有保障,而哪些情况下是不确定的的,从而避免跌入数据不可见的迷障。另一方面,我们在设计和实现程序时,不要过度依赖golang的Hanpens Before规则,而使程序变得晦涩难懂。

 

Hanpens Before (先行发生)

理解Hanpens Before

为了提高程序执行时的效率,编译器和处理器常常会对指令做重排序。重排序分三种类型:
(1)编译器优化的重排序:编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
(2)指令级并行的重排序:现代处理器采用了指令级并行技术(Instruction-Level Parallelism, ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
(3)内存系统的重排序: 由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。


在一个goroutine中,读和写必须按照程序指定的顺序执行。也就是说,只有当指令重排没有改变既定的代码的逻辑顺序时,编译器和处理器才可以重新排序在单个goroutine中执行的读写操作。反过来讲,只要程序的既定逻辑没有被改变,那么编译器和处理器就可以按需要对指令进行重新排序。由于这种重新排序,一个goroutine观察到的执行顺序可能与另一个goroutine感知到的执行顺序不同。例如:

有全局变量a 和b,在goroutine1 中有如下代码:

// in goroutine1

a = 10         // action 1
b = 1          // action 2
c = a + b     // action 3

fmt.Println(c)  // action 4

同时,在goroutine2 中,读取a和b的内容:

// in goroutine2

for {
    if b == 1 {
        fmt.Println(a)
        break
    }
}

按程序表达,goroutine2中,当 b == 1 达成时,a应该已经被赋值了,程序期望输出 a的值是10。 但实际运行时,却发现输出a 的值有时是10,有时是0。 这是因为,在goroutine1 中,a和b的赋值操作,并没有逻辑依赖关系,所以编译器和处理器可以对该两条指令进行重新排序,导致实际b=1 可能先于a =10 被执行;此时goroutine2中,看到b==1 已经达成,但此时a却依旧保持初始值0(这里有两种情况会导致看见a==0,其一,goroutine1 尚未执行a=10;其二,goroutine1执行了a=10,但由于CPU cache 缓存,goroutine2 并未取到更新后的a的值)。

这种场景下,我们说:

action 1 Hanpens Before action 2 条件不必要”。

当 Hanpens Before 条件不满足,且没有额外引入同步机制确保操作顺序时,程序就可能出现未知状态。例如一个经典范式:

var(
 inited = false
 config *Config
)

func doInit() {
    c := loadConfig()
    if c == nil {
        panic("load config error")
   }
   config =c
   inited = true
}

func main() {
    go doInit()
    for !inited {
         // wait init done
     }
    doWithConfig(config)
}

上面的例子,是一个经典的异步初始化的模型范例,甚至在一些生产代码也可以见到同样的应用。但是,上面的程序,有可能出现在main流程中,等待初始化成功后,执行后面操作时,却得到config是一个空指针而引发程序崩溃。

 

继续回到本节开篇的例子,在goroutine1 中对 a,b,c 进行赋值,现在有goroutine3 ,代码如下:

// in goroutine 3

for {
     if c == 11 {
          fmt.Println(a)
          break
    }
}

运行程序发现,goroutine3 打印a 的值是明确的,都是10。这是因为,在goroutine1 中, c= a+b 要求在对c 赋值之前,a 和b 必须要完成赋值。即:

action 1 和action 2 Hanpens Before action 3 条件必要

 

总结

Hanpens Before 描述了两个行为的明确的先后关系: actionA 先于 actionB 发生。这种关系,通常是有一些基础策略,逻辑依赖,实现原理所决定的。比如,golang中,程序启动的流程为:

(1)从main 包开始执行,先引入所依赖的包;所述依赖包还有依赖,则递归引入依赖;

(2)引入依赖包后,执行该依赖包的init函数(如果有);

(3)从main 包的main函数开始执行。

通过以上流程,不难得出:

(1)加载程序所有依赖包  Hanpens Before  main()函数开始执行;

(2)依赖包的init函数执行完毕  Hanpens Before main() 函数开始执行;

(3)在加载过程中,软件包完成其依赖包的加载 Hanpens Before 该软件包init()函数开始执行。

 

另一方面,当两个行为或过程满足 Hanpens Before 的必要条件时,说明他们的执行顺序是明确有序的,它们就不会出现数据静态,无需额外的同步机制。

下面我们将详细说明,golang中那些过程是满足Hanpens Before 条件的。

 

golang 中符合Hanpens Before的过程组合

Go的初始化

程序初始化运行在单个goroutine(主协程)中,并且该goroutine能够创立其余并发运行的goroutine。

  • 如果包p导入了包q,则q包init函数执行完结 先于 p包init函数的执行。
  • main函数的执行发生在所有init函数执行完成之后。

goroutine的创建和退出

  • goroutine的创建先于goroutine的执行。
  • 注意)goroutine的退出不与程序中的任何过程形成Hanpens Before必要条件;即goroutine的退出是无法预测的。如果用一个goroutine察看另一个goroutine的退出,请应用锁(waitgroup)或者Channel来保障绝对有序。

关于goroutine的创建 先于 goroutine 的执行,感觉上像句废话;个人理解是:创建goroutine 的语句(尤其是复合语句)的执行,一定先于goroutine开始执行之前。举例:

func delay( sec int) int {
    time.Sleep(time.Second * sec)
    return  sec
}

go fmt.Println(delay(1))

上述代码执行时,创建goroutine的过程会因为调用delay 函数而阻塞 main goroutine 一秒钟。

channel的发送和接收

  • 对channel 的send 动作开始  先于 对channel的接收完成

见下面的示例代码:

var c = make(chan int, 10)
var a string

func f() {
	a = "hello, world"
	c <- 0
}

func main() {
	go f()
	<-c
	print(a)
}

上面的程序 打印的输出一定是“heallo,world”。因为主协程是在读取到管道返回值后,才读取a的值;当管道能够读取到值时,写管道的动作一定先前发生了;f协程中,对a赋值先于写channel(这点是程序的检测点机制,不展开);因而可以推论出:对a的赋值 先于 对a的读取。因此该程序的读写流程是严格同步的。

 

  • 对channel的关闭动作 先于 读端因为channel已关闭而读到一个零值

见下面的示例代码:

var c = make(chan int, 10)
var a string

func f() {
	a = "hello, world"
	close(c)
}

func main() {
	go f()
	<-c
	print(a)
}

上面的程序 打印的输出一定是“heallo,world”。因为读取端获取到返回值时,f协程的close动作一定先发生了。

 

  • 在一个无缓存的channel上,接收完成 先于 发送完成
见下面的示例:
var c = make(chan int)
var a string


func f() {
    a = "hello, world"
    <-c
}


func main() {
    go f()
    c <- 0
    print(a)
}
上面的程序 打印的输出一定是“heallo,world”。因为主协程通过无缓冲channel向 f 协程写入,当主协程写入完成返回时,f协程的接收必定先于此完成,故而对a的写入和读取是严格同步的。
 
  • 容量为C的channel,第k个接收完成 先于 第k +C个发送完成前

本质上,是带缓冲的channel当buffer满后,发生端会阻塞等待一个buffer空闲后、完成写入后返回。应用该原理,一个限制并发数的调度队列可以简单实现为:

var limit = make(chan int, 3)

func scheduleWorker(w interface{}) {
    limit <- 1
    w()
    <-limit
}

func main() {
	for _, w := range work {
		go scheduleWorker(w)
	}
	select{}
}

上面代码, scheduleWorker中,worker 被调度后,先尝试往channel中写入一个标记;如果此时buffer 未满则可以顺利写入(类似于获取令牌),程序继续执行真正的worker任务;相反,如果channel的buffer已满(已有三个任务正在执行),那么当前任务会阻塞在channel的写入上,直到其他任务执行完毕后,从channel中读取一个数据(类似于补充令牌),触发写端继续写入。

 

  • 对于任何sync.Mutex或sync.RWMutex变量l以及n < m,第n次l.Unlock()的调用先于第m次l.Lock()的调用返回。

见下面示例代码:

var l sync.Mutex
var a string

func f() {
	a = "hello, world"
	l.Unlock()
}

func main() {
	l.Lock()   // 第一次锁
	go f()     // 执行一次解锁
	l.Lock()   // 第二次锁
	print(a)
}

上面程序输出a 的值“hello, world”。主协程中,第一次获取锁成功,当第二次再尝试获取锁时,程序被阻塞,直到锁状态被Unlock解除。这样,a的写入和读取是严格有序的。

 

  • 对于读写锁l(sync.RWMutex),当有写锁存在时,任意的读加锁(l.RLock)返回 在 所有写锁解锁(l.Unlock)完成之后
  • 对于读写锁l(sync.RWMutex), 当有读锁存在时,任意的写加锁(l.Lock)返回 在 所有的读锁解锁(l.RUnlock)完成之后

总结读写锁的持锁规则:

(1)当存在读加锁(l.Rlock)时,新的读加锁无需等待之前的读锁解锁,即可并行持锁(相较与互斥锁的主要改进);

(2)当存在读加锁(l.Rlock)时,写锁(l.Lock)必须等待所有的读锁解锁(l.RUnlock)完成后,方能持锁;

(3)当存在写加锁(l.Lock)时,读锁(l.Rlock)必须等待所有的写锁解锁(l.Unlock)完成后,方能持锁;

(4)当存在写加锁(l.Lock)时,写锁(l.Lock)必须等待所有的写锁解锁(l.Unlock)完成后,方能持锁。

 

Once

  • 使用once进行初始化,once.do(f)的返回,一定在 f函数执行完成之后。

 once本质上是一个封装的单例模式,内部采用了互斥锁,确保多个goroutine 并发调用once.do进行初始化,其中只有一个协程可以获取到执行f的权限;其余协程都等待f执行完成后方可以返回。

 

原子操作

  • sync/atomic中提供的API 可以在并发协程中调用而保持严格的可见性。  

 

Finalizers

  • 通过runtime包对实例设置 finalizers函数,SetFinalizer(x, f) 完成 先于 f 函数的执

这个与创建协程的逻辑一致。

0条评论
0 / 1000
huskar
18文章数
2粉丝数
huskar
18 文章 | 2 粉丝
原创

Golang 内存模型详解

2023-05-29 05:46:03
52
0

写在前面的话

数据竞态:当存在两个或以上的goroutine,对一个数据,同时进行读写操作时(非原子操作),我们称之为该数据存在竟态。在竟态条件下,读者可能读到非一致性的数据:比如一个结构体数据,goroutine1 正在读v1版的数据;此时goroutine2 进行数据更新到v2;导致goroutine1读取到的数据部分是v1版的数据,部分是v2版的数据。

当多个goroutine操作存在竟态的数据时,必须将并发操作进行顺序化,避免出现数据不一致。在golang中可以实现操作顺序化的方法有:基于通道(channel)通信,使用同步原语(如sync包中的Mutex、RWMutex等),使用原子操作(sync/atomic)等。

另一方面,Golang内存模型,定义了一些限制条件,当程序遵循这些限制条件时,就可以实现数据免竟态。这些限制条件被称之为“Hanpens Before”规则。我们将在下一章重点说明。

警告:

理解和掌握golang 的 Hanpens Before规则,有助于我们了解哪些情况下,数据的可见性是有保障,而哪些情况下是不确定的的,从而避免跌入数据不可见的迷障。另一方面,我们在设计和实现程序时,不要过度依赖golang的Hanpens Before规则,而使程序变得晦涩难懂。

 

Hanpens Before (先行发生)

理解Hanpens Before

为了提高程序执行时的效率,编译器和处理器常常会对指令做重排序。重排序分三种类型:
(1)编译器优化的重排序:编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
(2)指令级并行的重排序:现代处理器采用了指令级并行技术(Instruction-Level Parallelism, ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
(3)内存系统的重排序: 由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。


在一个goroutine中,读和写必须按照程序指定的顺序执行。也就是说,只有当指令重排没有改变既定的代码的逻辑顺序时,编译器和处理器才可以重新排序在单个goroutine中执行的读写操作。反过来讲,只要程序的既定逻辑没有被改变,那么编译器和处理器就可以按需要对指令进行重新排序。由于这种重新排序,一个goroutine观察到的执行顺序可能与另一个goroutine感知到的执行顺序不同。例如:

有全局变量a 和b,在goroutine1 中有如下代码:

// in goroutine1

a = 10         // action 1
b = 1          // action 2
c = a + b     // action 3

fmt.Println(c)  // action 4

同时,在goroutine2 中,读取a和b的内容:

// in goroutine2

for {
    if b == 1 {
        fmt.Println(a)
        break
    }
}

按程序表达,goroutine2中,当 b == 1 达成时,a应该已经被赋值了,程序期望输出 a的值是10。 但实际运行时,却发现输出a 的值有时是10,有时是0。 这是因为,在goroutine1 中,a和b的赋值操作,并没有逻辑依赖关系,所以编译器和处理器可以对该两条指令进行重新排序,导致实际b=1 可能先于a =10 被执行;此时goroutine2中,看到b==1 已经达成,但此时a却依旧保持初始值0(这里有两种情况会导致看见a==0,其一,goroutine1 尚未执行a=10;其二,goroutine1执行了a=10,但由于CPU cache 缓存,goroutine2 并未取到更新后的a的值)。

这种场景下,我们说:

action 1 Hanpens Before action 2 条件不必要”。

当 Hanpens Before 条件不满足,且没有额外引入同步机制确保操作顺序时,程序就可能出现未知状态。例如一个经典范式:

var(
 inited = false
 config *Config
)

func doInit() {
    c := loadConfig()
    if c == nil {
        panic("load config error")
   }
   config =c
   inited = true
}

func main() {
    go doInit()
    for !inited {
         // wait init done
     }
    doWithConfig(config)
}

上面的例子,是一个经典的异步初始化的模型范例,甚至在一些生产代码也可以见到同样的应用。但是,上面的程序,有可能出现在main流程中,等待初始化成功后,执行后面操作时,却得到config是一个空指针而引发程序崩溃。

 

继续回到本节开篇的例子,在goroutine1 中对 a,b,c 进行赋值,现在有goroutine3 ,代码如下:

// in goroutine 3

for {
     if c == 11 {
          fmt.Println(a)
          break
    }
}

运行程序发现,goroutine3 打印a 的值是明确的,都是10。这是因为,在goroutine1 中, c= a+b 要求在对c 赋值之前,a 和b 必须要完成赋值。即:

action 1 和action 2 Hanpens Before action 3 条件必要

 

总结

Hanpens Before 描述了两个行为的明确的先后关系: actionA 先于 actionB 发生。这种关系,通常是有一些基础策略,逻辑依赖,实现原理所决定的。比如,golang中,程序启动的流程为:

(1)从main 包开始执行,先引入所依赖的包;所述依赖包还有依赖,则递归引入依赖;

(2)引入依赖包后,执行该依赖包的init函数(如果有);

(3)从main 包的main函数开始执行。

通过以上流程,不难得出:

(1)加载程序所有依赖包  Hanpens Before  main()函数开始执行;

(2)依赖包的init函数执行完毕  Hanpens Before main() 函数开始执行;

(3)在加载过程中,软件包完成其依赖包的加载 Hanpens Before 该软件包init()函数开始执行。

 

另一方面,当两个行为或过程满足 Hanpens Before 的必要条件时,说明他们的执行顺序是明确有序的,它们就不会出现数据静态,无需额外的同步机制。

下面我们将详细说明,golang中那些过程是满足Hanpens Before 条件的。

 

golang 中符合Hanpens Before的过程组合

Go的初始化

程序初始化运行在单个goroutine(主协程)中,并且该goroutine能够创立其余并发运行的goroutine。

  • 如果包p导入了包q,则q包init函数执行完结 先于 p包init函数的执行。
  • main函数的执行发生在所有init函数执行完成之后。

goroutine的创建和退出

  • goroutine的创建先于goroutine的执行。
  • 注意)goroutine的退出不与程序中的任何过程形成Hanpens Before必要条件;即goroutine的退出是无法预测的。如果用一个goroutine察看另一个goroutine的退出,请应用锁(waitgroup)或者Channel来保障绝对有序。

关于goroutine的创建 先于 goroutine 的执行,感觉上像句废话;个人理解是:创建goroutine 的语句(尤其是复合语句)的执行,一定先于goroutine开始执行之前。举例:

func delay( sec int) int {
    time.Sleep(time.Second * sec)
    return  sec
}

go fmt.Println(delay(1))

上述代码执行时,创建goroutine的过程会因为调用delay 函数而阻塞 main goroutine 一秒钟。

channel的发送和接收

  • 对channel 的send 动作开始  先于 对channel的接收完成

见下面的示例代码:

var c = make(chan int, 10)
var a string

func f() {
	a = "hello, world"
	c <- 0
}

func main() {
	go f()
	<-c
	print(a)
}

上面的程序 打印的输出一定是“heallo,world”。因为主协程是在读取到管道返回值后,才读取a的值;当管道能够读取到值时,写管道的动作一定先前发生了;f协程中,对a赋值先于写channel(这点是程序的检测点机制,不展开);因而可以推论出:对a的赋值 先于 对a的读取。因此该程序的读写流程是严格同步的。

 

  • 对channel的关闭动作 先于 读端因为channel已关闭而读到一个零值

见下面的示例代码:

var c = make(chan int, 10)
var a string

func f() {
	a = "hello, world"
	close(c)
}

func main() {
	go f()
	<-c
	print(a)
}

上面的程序 打印的输出一定是“heallo,world”。因为读取端获取到返回值时,f协程的close动作一定先发生了。

 

  • 在一个无缓存的channel上,接收完成 先于 发送完成
见下面的示例:
var c = make(chan int)
var a string


func f() {
    a = "hello, world"
    <-c
}


func main() {
    go f()
    c <- 0
    print(a)
}
上面的程序 打印的输出一定是“heallo,world”。因为主协程通过无缓冲channel向 f 协程写入,当主协程写入完成返回时,f协程的接收必定先于此完成,故而对a的写入和读取是严格同步的。
 
  • 容量为C的channel,第k个接收完成 先于 第k +C个发送完成前

本质上,是带缓冲的channel当buffer满后,发生端会阻塞等待一个buffer空闲后、完成写入后返回。应用该原理,一个限制并发数的调度队列可以简单实现为:

var limit = make(chan int, 3)

func scheduleWorker(w interface{}) {
    limit <- 1
    w()
    <-limit
}

func main() {
	for _, w := range work {
		go scheduleWorker(w)
	}
	select{}
}

上面代码, scheduleWorker中,worker 被调度后,先尝试往channel中写入一个标记;如果此时buffer 未满则可以顺利写入(类似于获取令牌),程序继续执行真正的worker任务;相反,如果channel的buffer已满(已有三个任务正在执行),那么当前任务会阻塞在channel的写入上,直到其他任务执行完毕后,从channel中读取一个数据(类似于补充令牌),触发写端继续写入。

 

  • 对于任何sync.Mutex或sync.RWMutex变量l以及n < m,第n次l.Unlock()的调用先于第m次l.Lock()的调用返回。

见下面示例代码:

var l sync.Mutex
var a string

func f() {
	a = "hello, world"
	l.Unlock()
}

func main() {
	l.Lock()   // 第一次锁
	go f()     // 执行一次解锁
	l.Lock()   // 第二次锁
	print(a)
}

上面程序输出a 的值“hello, world”。主协程中,第一次获取锁成功,当第二次再尝试获取锁时,程序被阻塞,直到锁状态被Unlock解除。这样,a的写入和读取是严格有序的。

 

  • 对于读写锁l(sync.RWMutex),当有写锁存在时,任意的读加锁(l.RLock)返回 在 所有写锁解锁(l.Unlock)完成之后
  • 对于读写锁l(sync.RWMutex), 当有读锁存在时,任意的写加锁(l.Lock)返回 在 所有的读锁解锁(l.RUnlock)完成之后

总结读写锁的持锁规则:

(1)当存在读加锁(l.Rlock)时,新的读加锁无需等待之前的读锁解锁,即可并行持锁(相较与互斥锁的主要改进);

(2)当存在读加锁(l.Rlock)时,写锁(l.Lock)必须等待所有的读锁解锁(l.RUnlock)完成后,方能持锁;

(3)当存在写加锁(l.Lock)时,读锁(l.Rlock)必须等待所有的写锁解锁(l.Unlock)完成后,方能持锁;

(4)当存在写加锁(l.Lock)时,写锁(l.Lock)必须等待所有的写锁解锁(l.Unlock)完成后,方能持锁。

 

Once

  • 使用once进行初始化,once.do(f)的返回,一定在 f函数执行完成之后。

 once本质上是一个封装的单例模式,内部采用了互斥锁,确保多个goroutine 并发调用once.do进行初始化,其中只有一个协程可以获取到执行f的权限;其余协程都等待f执行完成后方可以返回。

 

原子操作

  • sync/atomic中提供的API 可以在并发协程中调用而保持严格的可见性。  

 

Finalizers

  • 通过runtime包对实例设置 finalizers函数,SetFinalizer(x, f) 完成 先于 f 函数的执

这个与创建协程的逻辑一致。

文章来自个人专栏
后台开发技术分享
18 文章 | 4 订阅
0条评论
0 / 1000
请输入你的评论
0
0