go并发编程入门到放弃
并发和并行
并发:一个处理器同时处理多个任务。
并行:多个处理器或者是多核的处理器同时处理多个不同的任务.
前者是逻辑上的同时发生(simultaneous),而后者是物理上的同时发生。并发是指同时有很多事要做,你可以串行处理也可以并行处理。并行是指同时做多件事。
并发性(concurrency),又称共行性,是指能处理多个同时性活动的能力,并发事件之间不一定要同一时刻发生。
并行(parallelism)是指同时发生的两个并发事件,具有并发的含义,而并发则不一定并行。
如果某个系统支持两个或者多个动作(Action)同时存在,那么这个系统就是一个并发系统。如果某个系统支持两个或者多个动作同时执行,那么这个系统就是一个并行系统。并发系统与并行系统这两个定义之间的关键差异在于“存在”这个词。
在并发程序中可以同时拥有两个或者多个线程。这意味着,如果程序在单核处理器上运行,那么这两个线程将交替地换入或者换出内存。这些线程是同时“存在”的——每个线程都处于执行过程中的某个状态。如果程序能够并行执行,那么就一定是运行在多核处理器上。此时,程序中的每个线程都将分配到一个独立的处理器核上,因此可以同时运行。
我相信你已经能够得出结论——“并行”概念是“并发”概念的一个子集。也就是说,你可以编写一个拥有多个线程或者进程的并发程序,但如果没有多核处理器来执行这个程序,那么就不能以并行方式来运行代码。因此,凡是在求解单个问题时涉及多个执行流程的编程模式或者执行行为,都属于并发编程的范畴。
线程可以并发运行(每个线程在单个内核上轮流运行),也可以并行运行(每个线程在不同的内核上同时运行)。
进程、线程、协程和goroutine
Process -> Thread(LWP, lightweight process) -> Goroutine (一种lightweight userspace thread)
进程(process)
狭义定义:进程就是一段程序的执行过程例如启动的某个app。
广义定义:进程是一个具有独立功能的程序关于某个数据集合的一次运行活动。它是操作系统动态执行的基本单元,在传统的操作系统中,进程即是基本的分配单元,也是基本的执行单元。
进程是一个实体,每个进程都有自己的地址空间,一般情况下,包含文本区域、数据区域、堆栈
进程是执行中的程序,程序是一个没有生命的实体,只有处理器赋予程序生命时,它才能成为一个活动的实体,我们称之为进程
进程本身不会运行,是线程的容器。线程不能单独执行,必须组成进程
一个程序至少有一个进程,一个进程至少有一个线程
对于操作系统来讲,一个任务就是一个进程,比如开一个浏览器就是启动一个浏览器进程。打开一款app就是打开一个进程,例如打开香哈就是运行了一个进程。
有些进程还不止同时做一件事情。比如打开香哈,它可以同时进行看视频并且回复用户评论,在一个进程内部,要同时干多件事情。
进程状态
三状态模型
就绪:获取出CPU外的所有资源、只要处理器分配资源就可以马上执行
运行:获得处理器分配的资源,程序开始执行
阻塞:当程序条件不够的时候,需要等待提交满足的时候才能执行。
五状态模型
创建状态:进程在创建时需要申请一个空白PCB,向其中填写控制和管理进程的信息,完成资源分配。如果创建工作无法完成,比如资源无法满足,就无法被调度运行,把此时进程所处状态称为创建状态
就绪状态:进程已经准备好,已分配到所需资源,只要分配到CPU就能够立即运行
执行状态:进程处于就绪状态被调度后,进程进入执行状态
阻塞状态:正在执行的进程由于某些事件(I/O请求,申请缓存区失败)而暂时无法运行,进程受到阻塞。在满足请求时进入就绪状态等待系统调用
终止状态:进程结束,或出现错误,或被系统终止,进入终止状态。无法再执行
最早的并发能力:多进程并发,当一个进程阻塞的时候,切换到另外等待执行的进程,这样就能尽量把CPU利用起来,CPU就不浪费了。
线程(thread)
线程是一个程序里面不同的执行路径。
为什么引入线程
应用的需要。比如打开一个浏览器,我想一边浏览网页,一边下载文件,一边播放音乐。如果一个浏览器是一个进程,那么这样的需求需要线程机制。
开销的考虑。在进程内创建、终止线程比创建、终止进程要快。同一进程内的线程间切换比进程间的切换要快,尤其是用户级线程间的切换。线程之间相互通信无须通过内核(同一进程内的线程共享内存和文件)
性能的考虑。多个线程中,任务功能不同(有的负责计算,有的负责I/O),如果有多个处理器,一个进程就可以有很多的任务同时在执行。
线程状态:
- 初始状态
实现Runnable接口和继承Thread可以得到一个线程类,new一个实例出来,线程就进入了初始状态。
- 就绪状态
就绪状态只是说你资格运行,调度程序没有挑选到你,你就永远是就绪状态。调用线程的start()方法,此线程进入就绪状态。当前线程sleep()方法结束,其他线程join()结束,等待用户输入完毕,某个线程拿到对象锁,这些线程也将进入就绪状态。当前线程时间片用完了,调用当前线程的yield()方法,当前线程进入就绪状态。锁池里的线程拿到对象锁后,进入就绪状态。
- 运行中状态
线程调度程序从可运行池中选择一个线程作为当前线程时线程所处的状态。这也是线程进入运行状态的唯一一种方式。
- 阻塞状态
阻塞状态是线程阻塞在进入synchronized关键字修饰的方法或代码块(获取锁)时的状态。
- 等待状态
处于这种状态的线程不会被分配CPU执行时间,它们要等待被显式地唤醒,否则会处于无限期等待的状态。超时等待处于这种状态的线程不会被分配CPU执行时间,不过无须无限期等待被其他线程显示地唤醒,在达到一定时间后它们会自动唤醒。
- 终止状态
当线程的run()方法完成时,或者主线程的main()方法完成时,我们就认为它终止了。这个线程对象也许是活的,但是,它已经不是一个单独执行的线程。线程一旦终止了,就不能复生。在一个终止的线程上调用start()方法,会抛出java.lang.IllegalThreadStateException异常。
java线程状态
用户级线程和内核级线程
用户态线程属于自己用户态创建、管理和销毁,运行模式也在用户态ring3
内核态则运行与内核中,由内核调度
内核线程由操作系统调度
用户线程由系统 或者基于系统的第三方支持库完成,如 pthread,c11提到但并没实现的轻量级多线程库等等.
// TODO Fxxk
进程和线程
进程是一个资源的容器,为进程里的所有线程提供共享资源,是对程序的一种静态描述;线程是计算机最小的调度和运行单位,是对程序的一种动态描述
开个QQ,开了一个进程;开了迅雷,开了一个进程。在QQ的这个进程里,传输文字开一个线程、传输语音开了一个线程、弹出对话框又开了一个线程。
进程就是程序在操作系统中的依次执行过程,是系统进行资源分配和调度的基本单位。
线程是进程的一个执行实例,是程序执行的最小单元,它是比进程更小的能独立运行的基本单位
一个进程可以创建和销毁多个线程,同时一个进程中的多个线程可以并发执行
一个程序至少有一个进程,一个进程至少有一个线程
区别
定义方面:进程是程序在某个数据集合上的一次运行活动;线程是进程中的一个执行路径。(进程可以创建多个线程)
角色方面:在支持线程机制的系统中,进程是系统资源分配的单位,线程是CPU调度的单位。
资源共享方面:进程之间不能共享资源,而线程共享所在进程的地址空间和其它资源。同时线程还有自己的栈和栈指针,程序计数器等寄存器。
独立性方面:进程有自己独立的地址空间,而线程没有,线程必须依赖于进程而存在。
开销方面。进程切换的开销较大。线程相对较小。(前面也提到过,引入线程也出于了开销的考虑。)
协程(coroutine)
协程,是在应用层模拟的线程,他避免了上下文切换的额外耗费,兼顾了多线程的优点。简化了高并发程序的复杂度。举个例子,一个高并发的网络服务器,每一个socket连接进来,服务器用一个协程来对他进行服务。代码非常清晰。而且兼顾了性能。
协程是与其他函数或方法一起并发运行的函数或方法。协程可以看作是轻量级线程。与线程相比,创建一个协程的成本很小。因此在协程应用中,常常会看到有数以千计的协程并发地运行。
线程和协程
对于 进程、线程,都是有内核进行调度,有 CPU 时间片的概念,进行 抢占式调度(有多种调度算法)。
对于 协程(用户级线程),这是对内核透明的,也就是系统并不知道有协程的存在,是完全由用户自己的程序进行调度的,因为是由用户程序自己控制,那么就很难像抢占式调度那样做到强制的 CPU 控制权切换到其他进程/线程,通常只能进行 协作式调度,需要协程自己主动把控制权转让出去之后,其他协程才能被执行到。
协程需要运行在线程之上,线程由CPU进行调度。
<colgroup><col width="243"><col width="262"><col width="234"></colgroup>
|
比较的点
|
线程
|
协程
|
|
数据存储
|
内核态的内存空间
|
一般是线程提供的用户态内存空间
|
|
切换操作
|
操作最终在内核层完成,应用层需要调用内核层提供的 syscall 底层函数
|
应用层使用代码进行简单的现场保存和恢复即可
|
|
切换开销
|
涉及模式切换(从用户态切换到内核态)、16个寄存器、PC、SP...等寄存器的刷新等
|
goroutine:只有三个寄存器的值修改 - PC / SP / DX
|
|
任务调度
|
由内核实现,抢占方式,依赖各种锁
|
由用户态的实现的具体调度器进行。例如 go 协程的调度器
|
|
语音支持程度
|
绝大部分编程语言
|
部分语言:Lua,Go,Python ...
|
|
实现规范
|
按照现代操作系统规范实现 无统一规范。
|
在应用层由开发者实现,高度自定义,比如只支持单线程的线程。不同的调度策略,等等
|
|
内存消耗方面
|
线程:8MB
|
goroutine:2KB
|
协程和线程有3种映射关系:
N:1,N个协程绑定1个线程,优点就是协程在用户态线程即完成切换,不会陷入到内核态,这种切换非常的轻量快速。但也有很大的缺点,1个进程的所有协程都绑定在1个线程上,一是某个程序用不了硬件的多核加速能力,二是一旦某协程阻塞,造成线程阻塞,本进程的其他协程都无法执行了,根本就没有并发的能力了。
1:1,1个协程绑定1个线程,这种最容易实现。协程的调度都由CPU完成了,不存在N:1缺点,但有一个缺点是协程的创建、删除和切换的代价都由CPU完成,有点略显昂贵了。
M:N,M个协程绑定1个线程,是N:1和1:1类型的结合,克服了以上2种模型的缺点,但实现起来最为复杂。
goroutine
Go中,协程被称为goroutine(Rob Pike说goroutine不是协程,因为他们并不完全相同),它非常轻量,一个goroutine只占几KB,并且这几KB就足够goroutine运行完,这就能在有限的内存空间内支持大量goroutine,支持了更多的并发。虽然一个goroutine的栈只占几KB,但实际是可伸缩的,如果需要更多内容,runtime会自动为goroutine分配。
goroutine实现了M:N协程和线程的映射关系。
协程和goroutine
本质上,goroutine 就是协程。 不同的是,Golang 在 runtime、系统调用等多方面对 goroutine 调度进行了封装和处理,当遇到长时间执行或者进行系统调用时,会主动把当前 goroutine 的CPU (P) 转让出去,让其他 goroutine 能被调度并执行,也就是 Golang 从语言层面支持了协程。Golang 的一大特色就是从语言层面原生支持协程,在函数或者方法前面加 go关键字就可创建一个协程。
Go 调度器
内核调度,协程调度,进程的创建销毁都有内核调度完成,goroutine的创建运行销毁有GO的scheduler完成。
调度器的任务是在用户态完成goroutine的调度,而调度器的实现好坏,对并发实际有很大的影响,并且Go的调度器就是M:N类型的,实现起来也是最复杂。
Why
熟悉POSIX API的人都知道,POSIX的方案在很大程度上是对Unix process进场模型的一个逻辑描述和扩展,两者有很多相似的地方。 Thread有自己的信号掩码,CPU affinity等。但是很多特征对于Go程序来说都是累赘。 尤其是context上下文切换的耗时。另一个原因是Go的垃圾回收需要所有的goroutine停止,使得内存在一个一致的状态。垃圾回收的时间点是不确定的,如果依靠OS自身的scheduler来调度,那么会有大量的线程需要停止工作。
简而言之,就是基础架构做了个东西我们用起来不方便,在上面包装做了个新东西,比如我们的决策平台在dolphin上包装了一层。
Old Go Scheduler
2012年以前,go采用MG模型实现协程调度。
M,代表线程,它要运行goroutine。
Global G Queue,是全局goroutine队列,所有的goroutine都保存在这个队列中,goroutine用G进行代表。
M想要执行、放回G都必须访问全局G队列,并且M有多个,即多线程访问同一资源需要加锁进行保证互斥/同步,所以全局G队列是有互斥锁进行保护的。
老调度器有4个缺点:
创建、销毁、调度G都需要每个M获取锁,这就形成了激烈的锁竞争。
M转移G会造成延迟和额外的系统负载。比如当G中包含创建新协程的时候,M创建了G’,为了继续执行G,需要把G’交给M’执行,也造成了很差的局部性,因为G’和G是相关的,最好放在M上执行,而不是其他M'。
M中的mcache是用来存放小对象的,mcache和栈都和M关联造成了大量的内存开销和差的局部性。
系统调用导致频繁的线程阻塞和取消阻塞操作增加了系统开销。
New Go Scheduler
面对以上老调度的问题,Go设计了新的调度器,设计文稿:https://golang.org/s/go11sched
新调度器引入了:
P:Processor,它包含了运行goroutine的资源,如果线程想运行goroutine,必须先获取P,P中还包含了可运行的G队列。
work stealing:当M绑定的P没有可运行的G时,它可以从其他运行的M’那里偷取G。
现在,调度器中3个重要的缩写你都接触到了,所有文章都用这几个缩写,请牢记:
G: goroutine
M: 工作线程
P: 处理器,它包含了运行Go代码的资源,M必须和一个P关联才能运行G。
调度器的有两大思想:
复用线程:协程本身就是运行在一组线程之上,不需要频繁的创建、销毁线程,而是对线程的复用。在调度器中复用线程还有2个体现:1)work stealing,当本线程无可运行的G时,尝试从其他线程绑定的P偷取G,而不是销毁线程。2)hand off,当本线程因为G进行系统调用阻塞时,线程释放绑定的P,把P转移给其他空闲的线程执行。
利用并行:GOMAXPROCS设置P的数量,当GOMAXPROCS大于1时,就最多有GOMAXPROCS个线程处于运行状态,这些线程可能分布在多个CPU核上同时运行,使得并发利用并行。另外,GOMAXPROCS也限制了并发的程度,比如GOMAXPROCS = 核数/2,则最多利用了一半的CPU核进行并行。
调度器的两小策略:
抢占:在coroutine中要等待一个协程主动让出CPU才执行下一个协程,在Go中,一个goroutine最多占用CPU 10ms,防止其他goroutine被饿死,这就是goroutine不同于coroutine的一个地方。
全局G队列:在新的调度器中依然有全局G队列,但功能已经被弱化了,当M执行work stealing从其他P偷不到G时,它可以从全局G队列获取G。
上面提到并行了,关于并发和并行再说一下:Go创始人Rob Pike一直在强调go是并发,不是并行,因为Go做的是在一段时间内完成几十万、甚至几百万的工作,而不是同一时间同时在做大量的工作。并发可以利用并行提高效率,调度器是有并行设计的。
P M G
在scheduler中有三个非常重要的概念:P,M,G。
// Goroutine scheduler
// The scheduler's job is to distribute ready-to-run goroutines over worker threads.
//
// The main concepts are:
// G - goroutine.
// M - worker thread, or machine.
// P - processor, a resource that is required to execute Go code.
// M must have an associated P to execute Go code, however it can be
// blocked or in a syscall w/o an associated P.
//
// Design doc at https://golang.org/s/go11sched.
G: goroutine,运行在M之上
M: 工作线程,有内核调度
P: 处理器,它包含了运行Go代码的资源,M必须和一个P关联才能运行G。
自顶向下是调度器的4个部分:
全局队列(Global Queue):存放等待运行的G。
P的本地队列:同全局队列类似,存放的也是等待运行的G,存的数量有限,不超过256个。新建G'时,G'优先加入到P的本地队列,如果队列满了,则会把本地队列中一半的G移动到全局队列。
P列表:所有的P都在程序启动时创建,并保存在数组中,最多有GOMAXPROCS个。
M:线程想运行任务就得获取P,从P的本地队列获取G,P队列为空时,M也会尝试从全局队列拿一批G放到P的本地队列,或从其他P的本地队列偷一半放到自己P的本地队列。M运行G,G执行之后,M会从P获取下一个G,不断重复下去。
work stealing
调度流程
并发编程模型
工作类型
线程可以做两种类型的工作。第一个称为 CPU-Bound,第二个称为 IO-Bound。
CPU-Bound:这种工作类型永远也不会让线程处在等待状态,因为这是一项不断进行计算的工作。比如计算 π 的第 n 位,就是一个 CPU-Bound 线程。
IO-Bound:这是导致线程进入等待状态的工作类型。比如通过网络请求对资源的访问或对操作系统进行系统调用。
七天七并发模型
线程与锁:线程与锁模型有很多众所周知的不足,但仍是其他模型的技术基础,也是很多并发软件开发的首选。
函数式编程:函数式编程日渐重要的原因之一,是其对并发编程和并行编程提供了良好的支持。函数式编程消除了可变状态,所以从根本上是线程安全的,而且易于并行执行。
Clojure之道——分离标识与状态:编程语言Clojure是一种指令式编程和函数式编程的混搭方案,在两种编程方式上取得了微妙的平衡来发挥两者的优势。
actor:actor模型是一种适用性很广的并发编程模型,适用于共享内存模型和分布式内存模型,也适合解决地理分布型问题,能提供强大的容错性。
通信顺序进程(Communicating Sequential Processes,CSP):表面上看,CSP模型与actor模型很相似,两者都基于消息传递。不过CSP模型侧重于传递信息的通道,而actor模型侧重于通道两端的实体,使用CSP模型的代码会带有明显不同的风格。
数据级并行:每个笔记本电脑里都藏着一台超级计算机——GPU。GPU利用了数据级并行,不仅可以快速进行图像处理,也可以用于更广阔的领域。如果要进行有限元分析、流体力学计算或其他的大量数字计算,GPU的性能将是不二选择。
Lambda架构:大数据时代的到来离不开并行——现在我们只需要增加计算资源,就能具有处理TB级数据的能力。Lambda架构综合了MapReduce和流式处理的特点,是一种可以处理多种大数据问题的架构。
CSP和ACTOR
“Don’t communicate by sharing memory, share memory by communicating”(不要通过共享内存来通信,而应该通过通信来共享内存)
GO CSP
Go语言的诞生就是为了支持高并发,有2个支持高并发的模型:CSP和Actor。鉴于Occam和Erlang都选用了CSP(来自Go FAQ),并且效果不错,Go也选了CSP,但与前两者不同的是,Go把channel作为头等公民。
就像前面说的多线程编程太不友好了,Go为了提供更容易使用的并发方法,使用了goroutine和channel。goroutine来自协程的概念,让一组可复用的函数运行在一组线程之上,即使有协程阻塞,该线程的其他协程也可以被runtime调度,转移到其他可运行的线程上。最关键的是,程序员看不到这些底层的细节,这就降低了编程的难度,提供了更容易的并发。
Goroutine和Channel
Channel
channel容易再犯错,甚至于比使用传统sync包下的同步原语的错误率还要高,牢记异常的情况:
close已经close的channel也会panic。
利用channel可以实现锁,并且可以实现TryWithTimeout方法,因为利用Go的内存模型可以保障,但是正常情况channel和mutex有不同的应用场景。
Channel
传递数据的owner
分发任务
交流异步结果
任务编排
Mutex
cache
状态
临界区
Channel的一些应用模式可以参考另一篇文章: Go Channel 应用模式
Go内存模型
内存模型描述了线程(goroutine)通过内存的交互,以及对数据的共享使用。
Java语言是第一个详细描述其内存模型的流行的编程语言。
它并不是描述内存是如何分配的,而是定义了:
对同一个变量,如何保证在一个goroutine对此变量读的时候,能观察到其它goroutine对此变量的写。
描述这种顺序关系的术语叫做happen before。
单个goroutine内
执行顺序和代码编写的顺序是一致的(有reorder,也不影响理解,可以按照编写顺序进行分析)
包级别的init函数
在单个goroutine中执行
最底层引入的包的init先执行。之后再是main函数。
提供问题: 同一个包下可以定义多个init函数吗?
go语句
goroutine的创建happens before所有此goroutine中的操作
goroutine的销毁happens after所有此goroutine中的操作
channel
第n个send一定happen before第n个receive完成, 不管是buffered channel还是unbuffered channel
对于capacity 为m的channel,第n个receive一定happen before第 (n+m) send完成
m=0 unbuffered。第n个receive一定happen before第n个send完成
channel的close一定happen before receive端得到通知,得到通知意味着receive收到一个因为channel close而收到的零值
注意 send/send completes,receive/receive completes的区别
Mutex/****RWMutex
对于Mutex/RWMutx m, 第n个成功的 m.Unlock 一定happen before 第 n+1 m.Lock方法调用的返回
对于RWMutex rw,如果它的第n个rw.Lock已返回,那么它的第n个成功的rw.Unlock的方法调用一定happen before 任何一个 rw.RLock方法调用的返回(它们 happen after 第n个rw.Lock方法调用返回)
对于RWMutex rw,如果它的第n个rw.RLock已返回,接着第m (m < n)个rm.RUnlock方法调用一定happen before 任意的 rw.Lock(它们happen after 第n个rw.RLock方法调用返回之后)
Waitgroup
对于 Waitgroup b, 对于其计数器不是0的时候,假如此时刻之后有一组wg.Add(n),并且我们确信只有最后一组方法调用使其计数器最后复原为0,那么这组wg.Add 方法调用一定happen before 这一时刻之后发生的wg.Wait
wg.Done()也是wg.Add(-1)
Once
- once.Do方法的执行一定happen before 任何一个once.Do方法的返回
Atomic
没有官方的保证
建议是不要依赖atomic保证内存的顺序
-
5045 历史悠久的讨论,还没close
GO并发编程
Mutex
最常用的sync包下的同步原语之一。
自2008年开始,经过了几次大的修改,加入了公平性和性能综合的考量、饥饿的处理,今年又进行了内敛的优化,对功能和性能都有了很好的提升。
内部结构使用state标记是否加锁、唤醒、饥饿等状态,使用高位来记录等待者的数量。
虽然state是unexported的,但是你可以通过unsafe包hack方式读取这些状态。
Unlock未加锁或者已解锁的Mutex会panic
Mutex不会比较当前请求的goroutine是否已经持有这个锁,所以可以一个goorutine Lock,另一个goroutine Unlock,但是慎用,避免死锁
非重入锁, Java程序员转Go容易犯的错,会导致死锁。 如果想重入,使用扩展的同步原语。
RWMutex
读写锁对于读进行了优化,适合写少读多的状态,对并发的读很适合。
如果有goroutine持有了RWMutex,那么只可能被一组reader持有,或者被一个writer持有。
如果已经有一组reader持有了读写锁,这个时候如果writer调用Lock,它会被阻塞。接着如果有reader调用RLock, 等前面那组readerUnlock后, writer优先获取锁
不要在递归调用读锁。因为上一条的规定,递归调用锁容易导致死锁
可以将读锁返回成一个Locker的接口
Cond
类似Monitor,提供了通知的机制,可以Broadcast通知所有Wait的goroutine,也可以Signal通知某一个Wait的goroutine。
Cond初始化的时候要传入一个Locker接口的实现,这个Locker可以用来保护条件变量。
Broadcast和Signal不需要加锁调用,但是调用Wait的时候需要加锁。
Wait执行中有解锁重加锁的过程,在这个期间对临界区是没有保护的。
一定要使用for循环来检查条件是否满足,因为随时都可以触发通知。
Waitgroup
也是最常用的sync包下的同步原语之一。
内部通过一个计数器来记录waiter。
在Wait之前可以设置这个计数器的数量。等这个计数器为0的时候,所有等待的goroutine都都会解除等待,继续执行。
Add方法可以增加计数,也可以传入负值减少技术,但是如果计数器小于0的情况下会panic。
Done方法是利用-1实现的,因此Done的次数如果多于计数器,会panic。
Wait调用多次没问题,只要计数器为0,它就不会阻塞。
并发 Add和Wait会panic。
前一个Wait还没有完成就Add也会panic。
所以Waitgroup是可以 重用的,但是一定等前一个Wait完成后再重用。
Once
用来初始化一次,比如实现单例,单元测试时环境的准备。
不要在传给Do的函数中调用这个Once,否则会死锁。
即使传入的这个函数会panic,Once也认为它已经初始化了。
Go单例的实现:
常量
package 变量 (eager)
init函数 (eager)
GetInstance() (lazy)
通过sync.Once或者类似实现
A XXX must not be copied after first use.
看上面的同步原语的 godoc,都有这么一句话。对象使用后就不能被复制了。
这是因为使用后这些对象都是有状态的,复制过去也会把状态复制过去,比如已加锁的状态,这不是我们期望的。
可以通过go vet工具检查。
如果你定义的struct也想有这个功能,可以使用noCopy这种经济的方式,定义Locker接口,让vet工具也能检查。
简单的复制是容易看出来的,很多隐藏的复制检查可以通过工具。
Pool
临时对象池
可能在任何时候任意的对象都可能被移除
可以安全地并发访问
装箱/拆箱
tcp、数据库连接池的话不要使用它,使用专门的池。
标准库中有的池的实现使用它,有的需要永久持有的对象不使用它,而是使用链表,比如rpc。
用它做buffer池要注意,避免内存泄漏。Pool的官方例子和标准库fmt、json中都有这个坑。标准库中已经修复了。
Map
使用空间换时间的方式,提供下面两个场景下的性能:
设置一次,多次读,比如cache
多个goroutine并发的读、写、更新不同的key
有以下的考量:
装箱/拆箱
Range进行遍历,可能会加锁
没有Len方法,并且也不会添加
ReentrantLock
标准库sync下的Mutex是不能重入的,如果想实现重入的话,可以利用:
goid:用来标记谁持有了当前锁,重入了几次
全局id:或者你自己维护一个全局id,但是实现的结构不再满足Locker接口
可重入锁也叫做递归锁,但是叫可重入锁更准确些,因为可重入可不只递归这么一种情况。
Semaphore
Dijkstra提出并发访问通用资源的并发原语,使用PV原语提供对临界区的保护。
二进制(取值0,1)的semaphore提供了锁的功能。
计数器semaphore提供了对一组资源的保护。
包 golang.org/x/sync/semaphore。
标准库内部的semaphore提供了休眠/唤醒的功能,用来实现基本同步原语的阻塞。
SingleFlight
并发的访问同一组资源的时候,只允许一个请求进行,这个请求把结果告诉其它等待者,避免雪崩的现象。
比如cache 失效的时候,只允许一个goroutine从数据库中捞数据回种,避免雪崩对数据库的影响。
扩展库中提供。
ErrGroup
应用于 half sync/half async的场景(这个设计模式以后有机会再介绍)。
有一组异步的任务需要处理,利用这个原语可以等待所有的异步任务完成,并获取第一个错误。
如果想得到所有的错误,利用额外的map变量进行记录。
使用Context可以实现遇到第一个错误就返回。
扩展包中提供。
bilibili扩展了这个原语,提供了限定并发数量的功能。
SpinLock
自旋锁
有时候很高效,因为当前CPU中运行的goroutine更有机会获取到锁
不公平
需要处理器忙等待
应用于竞争不是很激烈的状态
fslock
文件锁, 可以控制多个进程之间的同步。
concurrent-map
类似Java中的ConcurrentMap的设计思想,将key划分成一定数量的shard,每个shard一个锁,减少锁的竞争。
相对于sync.Map,可以应用写/删除/添加更频繁的场景。
原子操作
保证操作是原子的。
操作的数据
int32
int64
uint32
uint64
uintptr
unsafe.Pointer
操作方法
AddXXX (整数类型)
CompareAndSwapXXX:cas
LoadXXX:读取
StoreXXX:存储
SwapXXX:交换
Subtract
有符号的类型,可以使用Add负数
无符号的类型,可以使用AddUint32(&x, ^uint32(c-1)),AddUint64(&x, ^uint64(c-1))
无符号类型减一, AddUint32(&x, ^uint32(0)), AddUint64(&x, ^uint64(0))
Value
一个通用的对象,可以很方便的对struct等类型进行原子存储和加载。
由于不同的架构下对原子操作的支持是不一样的,有些架构师是不支持的。
fxxk...shiit..放弃
参考
进程、线程、协程和goroutine
https://studygolang.com/articles/20327
golang线程和协程
https://juejin.im/post/5d9a9c12e51d45781420fb7e
go调度器系列
https://segmentfault.com/a/1190000018451422
https://segmentfault.com/a/1190000018672300
https://segmentfault.com/a/1190000018775901
https://segmentfault.com/a/1190000018876007
golang-调度剖析
https://segmentfault.com/a/1190000016038785
https://segmentfault.com/a/1190000016611742
https://segmentfault.com/a/1190000017333717
深入理解go调度
https://www.cnblogs.com/sunsky303/p/9705727.html
并发模型比较
https://gobomb.github.io/post/high-concurrency-model/
go编发编程
https://colobu.com/2019/04/28/gopher-2019-concurrent-in-action/#%E5%86%85%E5%AE%B9%E5%88%92%E5%88%86