十年网站开发经验 + 多家企业客户 + 靠谱的建站团队
量身定制 + 运营维护+专业推广+无忧售后,网站问题一站解决
Go内存模型指定了在何种条件下可以保证在一个 goroutine 中读取变量时观察到不同 goroutine 中写入该变量的值。
创新互联长期为近千家客户提供的网站建设服务,团队从业经验10年,关注不同地域、不同群体,并针对不同对象提供差异化的产品和服务;打造开放共赢平台,与合作伙伴共同营造健康的互联网生态环境。为让胡路企业提供专业的成都网站建设、成都网站制作,让胡路网站改版等技术服务。拥有十年丰富建站经验和众多成功案例,为您定制开发。
通过多个协程并发修改数据的程序必须将操作序列化。为了序列化访问,通过channel操作或者其他同步原语( sync 、 sync/atomic )来保护数据。
如果你必须要阅读本文的其他部分才能理解你程序的行为,请尽量不要这样...
在单个 goroutine 中,读取和写入的行为必须像按照程序指定的顺序执行一样。 也就是说,只有当重新排序不会改变语言规范定义的 goroutine 中的行为时,编译器和处理器才可以重新排序在单个 goroutine 中执行的读取和写入。 由于这种重新排序,一个 goroutine 观察到的执行顺序可能与另一个 goroutine 感知的顺序不同。 例如,如果一个 goroutine 执行 a = 1; b = 2;,另一个可能会在 a 的更新值之前观察到 b 的更新值。
为了满足读写的需求,我们定义了 happens before ,Go程序中内存操作的局部顺序。如果事件 e1 在 e2 之前发生,我们说 e2 在 e1 之后发生。还有,如果 e1 不在 e2 之前发生、 e2 也不在 e1 之前发生,那么我们说 e1 和 e2 并发happen。
在单个 goroutine 中, happens-before 顺序由程序指定。
当下面两个条件满足时,变量 v 的阅读操作 r 就 可能 观察到写入操作 w
为了保证 r 一定能阅读到 v ,保证 w 是 r 能观测到的唯一的写操作。当下面两个条件满足时, r 保证可以读取到 w
这一对条件比上一对条件更强;这要求无论是 w 还是 r ,都没有相应的并发操作。
在单个 goroutine 中,没有并发。所以这两个定义等价:读操作 r 能读到最近一次 w 写入 v 的值。但是当多个 goroutine 访问共享变量时,它们必须使用同步事件来建立 happens-before 关系。
使用变量 v 类型的0值初始化变量 v 的行为类似于内存模型中的写入。
对于大于单个机器字长的值的读取和写入表现为未指定顺序的对多个机器字长的操作。
程序初始化在单个 goroutine 中运行,但该 goroutine 可能会创建其他并发运行的 goroutine。
如果包 p 导入包 q,则 q 的 init 函数的完成发生在任何 p 的操作开始之前。
main.main 函数的启动发生在所有 init 函数完成之后。
go 语句启动新的协程发生在新协程启动开始之前。
举个例子
调用 hello 将会打印 hello, world 。当然,这个时候 hello 可能已经返回了。
go 协程的退出并不保证发生在任何事件之前
对 a 的赋值之后没有任何同步事件,因此不能保证任何其他 goroutine 都会观察到它。 事实上,激进的编译器可能会删除整个 go 语句。
如果一个 goroutine 的效果必须被另一个 goroutine 观察到,请使用同步机制,例如锁或通道通信来建立相对顺序。
通道通信是在go协程之间传输数据的主要手段。在特定通道上的发送总有一个对应的channel的接收,通常是在另外一个协程。
channel 上的发送发生在对应 channel 接收之前
程序能保证输出 hello, world 。对a的写入发生在往 c 发送数据之前,往 c 发送数据又发生在从 c 接收数据之前,它又发生在 print 之前。
channel 的关闭发生在从 channel 中获取到0值之前
在之前的例子中,将 c-0 替换为 close(c) ,程序还是能保证输出 hello, world
无buffer channel 的接收发生在发送操作完成之前
这个程序,和之前一样,但是调换发送和接收操作,并且使用无buffer的channel
也保证能够输出 hello, world 。对a的写入发生在c的接收之前,继而发生在c的写入操作完成之前,继而发生在print之前。
如果该 channel 是buffer channel (例如: c=make(chan int, 1) ),那么程序就不能保证输出 hello, world 。可能会打印空字符串、崩溃等等。从而,我们得到一个相对通用的推论:
对于容量为C的buffer channel来说,第k次从channel中接收,发生在第 k + C 次发送完成之前。
此规则将先前的规则推广到缓冲通道。 它允许通过buffer channel 来模拟信号量:通道中的条数对应活跃的数量,通道的容量对应于最大并发数。向channel发送数据相当于获取信号量,从channel中接收数据相当于释放信号量。 这是限制并发的常用习惯用法。
该程序为工作列表中的每个条目启动一个 goroutine,但是 goroutine 使用 limit channel进行协调,以确保一次最多三个work函数正在运行。
sync 包中实现了两种锁类型: sync.Mutex 和 sync.RWMutex
对于任何的 sync.Mutex 或者 sync.RWMutex 变量 ,且有 nm ,第 n 个调用 UnLock 一定发生在 m 个 Lock`之前。
这个程序也保证输出 hello,world 。第一次调用 unLock 一定发生在第二次 Lock 调用之前
对于任何 sync.RWMutex 的 RLock 方法调用,存在变量n,满足 RLock 方法发生在第 n 个 UnLock 调用之后,并且对应的 RUnlock 发生在第 n+1 个 Lock 方法之前。
在存在多个 goroutine 时, sync 包通过 once 提供了一种安全的初始化机制。对于特定的 f ,多个线程可以执行 once.Do(f) ,但是只有一个会运行 f() ,另一个调用会阻塞,直到 f() 返回
从 once.Do(f) 对 f() 的单个调用返回在任何一个 once.Do(f) 返回之前。
调用 twoprint 将只调用一次 setup。 setup 函数将在任一打印调用之前完成。 结果将是 hello, world 打印两次。
注意,读取 r 有可能观察到了由写入 w 并发写入的值。尽管观察到了这个值,也并不意味着 r 后续的读取可以读取到 w 之前的写入。
有可能 g 会接连打印2和0两个值。
双检查锁是为了降低同步造成的开销。举个例子, twoprint 方法可能会被误写成
因为没有任何机制保证,协程观察到done为true的同时可以观测到a为 hello, world ,其中有一个 doprint 可能会输出空字符。
另外一个例子
和以前一样,不能保证在 main 中,观察对 done 的写入意味着观察对 a 的写入,因此该程序也可以打印一个空字符串。 更糟糕的情况下,由于两个线程之间没有同步事件,因此无法保证 main 会观察到对 done 的写入。 main 中的循环会一直死循环。
下面是该例子的一个更微妙的变体
尽管 main 观测到g不为nil,但是也没有任何机制保证可以读取到t.msg。
在上述例子中,解决方案都是相同的:请使用显式的同步机制。
基本设计思路:
类型转换、类型断言、动态派发。iface,eface。
反射对象具有的方法:
编译优化:
内部实现:
实现 Context 接口有以下几个类型(空实现就忽略了):
互斥锁的控制逻辑:
设计思路:
(以上为写被读阻塞,下面是读被写阻塞)
总结,读写锁的设计还是非常巧妙的:
设计思路:
WaitGroup 有三个暴露的函数:
部件:
设计思路:
结构:
Once 只暴露了一个方法:
实现:
三个关键点:
细节:
让多协程任务的开始执行时间可控(按顺序或归一)。(Context 是控制结束时间)
设计思路: 通过一个锁和内置的 notifyList 队列实现,Wait() 会生成票据,并将等待协程信息加入链表中,等待控制协程中发送信号通知一个(Signal())或所有(Boardcast())等待者(内部实现是通过票据通知的)来控制协程解除阻塞。
暴露四个函数:
实现细节:
部件:
包: golang.org/x/sync/errgroup
作用:开启 func() error 函数签名的协程,在同 Group 下协程并发执行过程并收集首次 err 错误。通过 Context 的传入,还可以控制在首次 err 出现时就终止组内各协程。
设计思路:
结构:
暴露的方法:
实现细节:
注意问题:
包: "golang.org/x/sync/semaphore"
作用:排队借资源(如钱,有借有还)的一种场景。此包相当于对底层信号量的一种暴露。
设计思路:有一定数量的资源 Weight,每一个 waiter 携带一个 channel 和要借的数量 n。通过队列排队执行借贷。
结构:
暴露方法:
细节:
部件:
细节:
包: "golang.org/x/sync/singleflight"
作用:防击穿。瞬时的相同请求只调用一次,response 被所有相同请求共享。
设计思路:按请求的 key 分组(一个 *call 是一个组,用 map 映射存储组),每个组只进行一次访问,组内每个协程会获得对应结果的一个拷贝。
结构:
逻辑:
细节:
部件:
如有错误,请批评指正。
1、学习曲线
它包含了类C语法、GC内置和工程工具。这一点非常重要,因为Go语言容易学习,所以一个普通的大学生花一个星期就能写出来可以上手的、高性能的应用。在国内大家都追求快,这也是为什么国内Go流行的原因之一。
2、效率
Go拥有接近C的运行效率和接近PHP的开发效率,这就很有利的支撑了上面大家追求快速的需求。
3、出身名门、血统纯正
之所以说Go语言出身名门,是因为我们知道Go语言出自Google公司,这个公司在业界的知名度和实力自然不用多说。Google公司聚集了一批牛人,在各种编程语言称雄争霸的局面下推出新的编程语言,自然有它的战略考虑。而且从Go语言的发展态势来看,Google对它这个新的宠儿还是很看重的,Go自然有一个良好的发展前途。我们看看Go语言的主要创造者,血统纯正这点就可见端倪了。
4、组合的思想、无侵入式的接口
Go语言可以说是开发效率和运行效率二者的完美融合,天生的并发编程支持。Go语言支持当前所有的编程范式,包括过程式编程、面向对象编程以及函数式编程。
5、强大的标准库
这包括互联网应用、系统编程和网络编程。Go里面的标准库基本上已经是非常稳定,特别是我这里提到的三个,网络层、系统层的库非常实用。
6、部署方便
我相信这一点是很多人选择Go的最大理由,因为部署太方便,所以现在也有很多人用Go开发运维程序。
7、简单的并发
它包含降低心智的并发和简易的数据同步,我觉得这是Go最大的特色。之所以写正确的并发、容错和可扩展的程序如此之难,是因为我们用了错误的工具和错误的抽象,Go可以说这一块做的相当简单。
8、稳定性
Go拥有强大的编译检查、严格的编码规范和完整的软件生命周期工具,具有很强的稳定性,稳定压倒一切。那么为什么Go相比于其他程序会更稳定呢?这是因为Go提供了软件生命周期的各个环节的工具,如go
tool、gofmt、go test。
部署简单。Go编译生成的是一个静态可执行文件,除了glibc外没有其他外部依赖。这让部署变得异常方便:目标机器上只需要一个基础的系统和必要的管理、监控工具,完全不需要操心应用所需的各种包、库的依赖关系,大大减轻了维护的负担。这和Python有着巨大的区别。由于历史的原因,Python的部署工具生态相当混乱【比如setuptools,distutils,pip,
buildout的不同适用场合以及兼容性问题】。官方PyPI源又经常出问题,需要搭建私有镜像,而维护这个镜像又要花费不少时间和精力。
并发性好。Goroutine和channel使得编写高并发的服务端软件变得相当容易,很多情况下完全不需要考虑锁机制以及由此带来的各种问题。单个Go应用也能有效的利用多个CPU核,并行执行的性能好。这和Python也是天壤之比。多线程和多进程的服务端程序编写起来并不简单,而且由于全局锁GIL的原因,多线程的Python程序并不能有效利用多核,只能用多进程的方式部署;如果用标准库里的multiprocessing包又会对监控和管理造成不少的挑战【我们用的supervisor管理进程,对fork支持不好】。部署Python应用的时候通常是每个CPU核部署一个应用,这会造成不少资源的浪费,比如假设某个Python应用启动后需要占用100MB内存,而服务器有32个CPU核,那么留一个核给系统、运行31个应用副本就要浪费3GB的内存资源。
良好的语言设计。从学术的角度讲Go语言其实非常平庸,不支持许多高级的语言特性;但从工程的角度讲,Go的设计是非常优秀的:规范足够简单灵活,有其他语言基础的程序员都能迅速上手。更重要的是Go自带完善的工具链,大大提高了团队协作的一致性。比如gofmt自动排版Go代码,很大程度上杜绝了不同人写的代码排版风格不一致的问题。把编辑器配置成在编辑存档的时候自动运行gofmt,这样在编写代码的时候可以随意摆放位置,存档的时候自动变成正确排版的代码。此外还有gofix,
govet等非常有用的工具。
执行性能好。虽然不如C和Java,但通常比原生Python应用还是高一个数量级的,适合编写一些瓶颈业务。内存占用也非常省。