目录
学习资料
书籍:
1. 并发
1.1. 概述
1.1.1. 概述
- 概述:
- Golang 使用Groutine和channels实现了
CSP(Communicating Sequential Processes)模型
,channles 在 goroutine 的通信和同步中承担着重要的角色。 - goroutine 是由 Go 语言的运行时调度完成,而
线程
是由操作系统调度完成;
- Golang 使用Groutine和channels实现了
1.1.2. 并发与并行
- 并发与并行
1. 并发 并发意味着程序在单位时间内是同时运行的;多线程程序在单核心的 cpu 上运行,称为并发。 2. 并行 并行是让不同的代码片段同时在不同的物理处理器上执行。 并行意味着程序在任意时刻都是同时运行的;多线程程序在多核心的 cpu 上运行,称为并行。
1.2. 高并发
- golang CAS
- CAS操作的优势是,可以在不形成临界区和创建互斥量的情况下完成并发安全的值替换操作。这可以大大的减少同步对程序性能的损耗。
- 当然,CAS操作也有劣势。在被操作值被频繁变更的情况下,CAS操作并不那么容易成功。
- golang的atomic支持了CAS
备注:
- CAS
CAS(Compare and swap)比较和替换 是 设计并发算法时用到的一种技术。
简单来说,比较和替换是使用一个期望值和一个变量的当前值进行比较,如果当前变量的值与我们期望的值相等,就使用一个新值替换当前变量的值。 - 原子操作:
- 多是用来在多线程场景下 进行计数;
2. 多线程
2.1. 协程 & 例程
-
例程
- 熟悉C/C ++语言的人都知道,一个例程也就是一个函数。当我们调用一个函数时,执行流程进入函数;
当函数执行完成后,执行流程返回给上层函数或例程。 - 期间,每个函数执行共享一个线程栈;函数返回后栈顶的内容自动回收。这就是例程的特点,也是现代操作系统都支持这种例程方式。
- 熟悉C/C ++语言的人都知道,一个例程也就是一个函数。当我们调用一个函数时,执行流程进入函数;
-
协程
- 协程与例程相对,从抽象的角度来说,例程只能进入一次并返回一次,而协程可能进入多次并返回多次
==补充:== 进程 线程 协程 进程: 进程是系统进行资源分配的基本单位,有独立的内存空间,单切换代价极高,进程间通信也比较麻烦 线程: 线程是CPU调度和分派的基本单位,线程依附于进程,与其他线程共享进程的资源, 仅有自己的(程序计数器,一组寄存器的值,和栈), 线程切换代价小(但是线程之间的切换可能会涉及用户态和内核态的切换), 由于共享进程资源,所以线程之间通信比较方便。 协程: 协程是一种用户态的轻量级线程,协程的调度完全由用户控制,独立的栈空间,共享堆空间 协程切换只需要保存和恢复任务的上下文,没有内核的开销。 协程间通信也比较简单(协程间本身是不可抢占的,由于操作系统的调度机制无法影响到它, 因此一般存在用户自定义的调度机制)(也可以这么说内核线程依然叫“线程(thread)”, 用户线程叫“协程(co-routine)".) 一个线程上可以跑多个协程,协程是轻量级的线程。
参考:
- go为什么这么快?(再探GMP模型)
- linux进程-线程-协程上下文环境的切换与实现 --协程(详尽) == 进程 线程 协程 ==
补充:
- 协程的实现
- php里 yield 实现;
- go的 goroutine 实现;
2.2. 异步与协程
关键字: yield
3. Channel
- 概述 :
Don’t communicate by sharing memory; share memory by communicating. (不要通过共享内存来通信,而应该通过通信来共享内存。) 这是作为 Go 语言的主要创造者之一的 Rob Pike 的至理名言。
- 用于多个 goroutine 之间进行通讯;
3.1. 同步 & 异步
-
同步:
1. 同步就是一个任务的完成需要依赖另外一个任务时,只有等待被依赖的任务完成后,依赖的任务才能算完成,这是一种可靠的任务序列。 2. 要么成功都成功,失败都失败,两个任务的状态可以保持一致。 3. golang 1. 默认为同步方式; 需要发送和接收配对;
-
异步:
1. 不需要等待被依赖的任务完成,只是通知被依赖的任务要完成什么工作,依赖的任务也立即执行,只要自己完成了整个任务就算完成了。 2. 至于被依赖的任务最终是否真正完成,依赖它的任务无法确定,所以它是不可靠的任务序列。 3. golang 1. 异步方式通过判断缓冲区决定是否阻塞; 2. 通常情况下, 异步 channel 可减少排队阻塞, 具有更高的效率;
-
golang 异步原理
==说明:== +++++++++++++++ | buf | -----> | x | x | x | x | x | | sendx | | recvx | | sendq | -----> +++++++++ | recvq | | g | ---> G1 | closed | | elem | ---> 6 | lock | | ... | +++++++++++++++ +++++++++ buf满时,用 sudog 包裹g和要发送的数据,入队sendq,并将当前gorutine进行gopark(m解除当前的g, m重新进入调度循环, g没有进入调度队列) 出现新接收方时,sendq 出队,从buf拷贝队头,从sender拷贝到队尾,goready(放入调度队列,等待被调度) 读取空channel时,用sudog包裹g和要发送的数据,入队recvq,gopark
3.2. 阻塞与非阻塞
-
概述
- 阻塞和非阻塞这两个概念与程序(线程)等待消息通知(无所谓同步或者异步)时的状态有关。
- 也就是说阻塞与非阻塞主要是程序(线程)等待消息通知时的状态角度来说的。
-
阻塞
- 阻塞调用是指调用结果返回之前,当前线程会被挂起,一直处于等待消息通知,不能够执行其他业务。函数只有在得到结果之后才会返回。
- 有人也许会把阻塞调用和同步调用等同起来,实际上它们是不同的。
对于同步调用来说,很多时候当前线程可能还是激活的,只是从逻辑上当前函数没有返回而已, 此时,这个线程可能也会处理其他的消息。还有一点,在这里先扩展下: (a) 如果这个线程在等待当前函数返回时,仍在执行其他消息处理,那这种情况就叫做 **同步非阻塞**; -- 其他事和排队查看 两行为之间进行切换; (b) 如果这个线程在等待当前函数返回时,没有执行其他消息处理,而是处于挂起等待状态,那这种情况就叫做 **同步阻塞**; --效率最低 所以同步的实现方式会有两种:同步阻塞、同步非阻塞;同理,异步也会有两种实现:异步阻塞、异步非阻塞; (c) **异步阻塞**: 异步的方法等待消息被触发; (d) **异步非阻塞**: 进行其他行为的过程中, 被通知排到号了; --效率最好;
- 对于阻塞调用来说,则当前线程就会被挂起等待当前函数返回;
-
非阻塞
- 非阻塞和阻塞的概念相对应,指在不能立刻得到结果之前,该函数不会阻塞当前线程,而会立刻返回。
- 虽然表面上看非阻塞的方式可以明显的提高CPU的利用率,但是也带了另外一种后果就是系统的线程切换增加。增加的CPU执行时间能不能补偿系统的切换成本需要好好评估;
-
总结:
- 同步/异步关注的是消息通知的机制,而阻塞/非阻塞关注的是程序(线程)等待消息通知时的状态。
参考:
3.3. channel特点
- 特点
1. 通道类型的值本身就是并发安全的,这也是 Go 语言自带的、唯一一个可以满足并发安全性的类型。 2. 一个通道相当于一个先进先出(FIFO)的队列。也就是说, 通道中的各个元素值都是严格地按照发送的顺序排列的,先被发送通道的元素值一定会先被接收。 3. 在任何时候,同时只能有一个 goroutine 访问通道进行发送和获取数据。 goroutine间通过通道就可以通信。 4. 对于同一个通道,发送操作之间是互斥的,接收操作之间也是互斥的。 1. 发送操作和接收操作中对元素值的处理都是不可分割的。 2. 发送操作在完全完成之前会被阻塞。接收操作也是如此。
3.4. 语法
3.4.1. 声明 初始化
-
通道类型:
// 声明形式: var chanName chan ElementType // 无缓冲的整型通道 : len cap = 0 unbuffered := make(chan int) // 有缓冲的字符串通道 : len 代表没有被读取的元素数, cap 代表整个通道的容量。 buffered := make(chan string, 10) ch2 := make(chan interface{}) // 创建一个空接口类型的通道, 可以存放任意格式 ==单向通道:== var 通道实例 chan<- 元素类型 // 只能发送通道 var 通道实例 <-chan 元素类型 // 只能接收通道 // 只能发不能收的通道。 var uselessChan = make(chan<- int, 1) // 只能收不能发的通道。 var anotherUselessChan = make(<-chan int, 1) var ch1 chan int // ch1是一个正常的channel,不是单向的 var ch2 chan<- float64// ch2是单向channel,只用于写float64数据 var ch3 <-chan int // ch3是单向channel,只用于读取int数据 ==双向通道 转 单向通道== // 不能将单向 channel 转换为普通 channel。 ch4 := make(chan int) ch5 := <-chan int(ch4) // ch5就是一个单向的读取channel ch6 := chan<- int(ch4) // ch6 是一个单向的写入channel
-
nil
通道var ch chan int // 只声明,并没有初始化 make new fmt.Printf("ch is %v\n", ch) // ch is <nil> // 读取 写入 1. 对于 值为 nil 的通道 ,不论它的具体类型是什么,对它的发送操作和接收操作都会`永久地处于阻塞状态`。 1. 它们所属的 goroutine 中的任何代码,都不再会被执行。 2. 注意,由于通道类型是引用类型,所以它的零值就是 nil 。 1. 换句话说,当我们只声明该类型的变量但没有用 make 函数对它进行初始化时,该变量的值就会是 nil。 2. 我们一定不要忘记初始化通道!
3.4.2. 接收 发送
-
格式
1. 发送数据 ch := make(chan int) //整数类型通道, 无缓存 通道变量 <- 值 // 发送到通道 ch <- 5 2. 接收数据 1) 阻塞接收数据 阻塞模式接收数据时,将接收变量作为<-操作符的左值,格式如下: data := <-ch 执行该语句时将会阻塞,直到接收到数据并赋值给 data 变量。 1) 非阻塞接收数据 使用非阻塞方式从通道接收数据时,语句不会发生阻塞,格式如下: data, ok := <-ch // 从通道接收数据 data:表示接收到的数据。未接收到数据时,data 为通道类型的零值。 ok:表示是否接收到数据。 可能造成高的 CPU 占用; 备注: 可以配合 select 和计时器 channel 进行接收超时检测 //== 多个元素的接收 for data := range ch { } // 收发方式 # 单向 1. 将 channel 隐式转换为单向队列, 只收或是只发; # 选择 1. 同时处理多个 channel, 可使用 select 选择一个channel进行收发, 或执行 default case; # 模式 收发
-
有无缓存通道
- 有缓存通道:
- 主要用于通信
- 无缓存通道:
- 可用于通信;
- 用于两个 goroutine 之间同步;
- 有缓存通道:
-
发送操作和接收操作在什么时候会引发
panic
?- 对于一个已初始化,但并未关闭的通道来说,收发操作一定不会引发 panic。
- 但是通道一旦关闭,再对它进行发送操作,就会引发 panic。
- 另外,如果我们试图关闭一个已经关闭了的通道,也会引发 panic。
- 补充:
- 读 已经关闭的
chan
能一直读到东西, 但是读到的内容根据通道关闭前是否有元素而不同;- 若
chan
关闭前, buffer内有元素还未读, 会正确读到chan
内的值, 且二参数 bool 返回 true; - 若
chan
关闭前, buffer内无剩余元素,chan
内无值, 读取正常, 值为chan
的默认零值, 二参数 bool 为 false;
注意: 接收操作是可以感知到通道的关闭的,并能够安全退出;
- 若
- 读 已经关闭的
-
总结:
参考:
3.4.3. 关闭
-
概述:
- 被关闭的通道不会被置为 nil 。如果尝试对已经关闭的通道进行发送,将会触发 panic; --发送数据
- 从 已经关闭的通道接收数据或者正在接收数据时, 将会接收到通道类型的零值,然后停止阻塞并返回; --接收数据
-
格式:
// 关闭通道 close(ch) // 判断通道是否关闭 v, ok := <-ch
参考:
4. Goroutine
参考:
- Go 学习笔记(22)— 并发(01)[进程、线程、协程、并发和并行、goroutine 启动、goroutine 特点,runtime 包函数]
- Go 学习笔记(24)— 并发(03)[通道特点、通道声明、通道发送/接收/关闭、单向通道]
4.1. 概述
-
概述
- 以通讯来共享内存的CSP模式;
- goroutine是一种非常轻量级的实现, 可以在单个进程中只允许成千上万的并发任务;
-
基本语法
go func() { println("Hello, World!") }() // 开启一个 goroutine 来打印输出 // 可以借助 for循环, 开启多个协程; for i := 0; i < 100; i++ { w.Add(1) go func(n int) { fmt.Println(n) w.Done() }(i)
- 简介:
4.2. 特点
-
特点:
1. go 的执行是非阻塞的, 不会等待; 2. go 后面的函数的返回值会被忽略; 3. 调度器不能保证多个 goroutine 的执行次序; 4. 没有父子 goroutine 的概念,所有的 goroutine 是平等地被调度和执行的; 5. Go 程序在执行时会单独为 main 函数创建一个 goroutine , 即 main goroutine, 遇到其他 go 关键字时再去创建其他的 goroutine , 每个 go 创建一个goroutine; 6. 主函数返回时,所有的 goroutine 都会被直接打断,程序退出; 7. Go 没有暴露 goroutine id 给用户,所以不能在一个 goroutine 里面显式地操作另一个 goroutine , 不过 runtime 包提供了一些函数访问和设置 goroutine 的相关信息;
==示例代码:== 1. 非阻塞 f() // 调用f()函数,并等待f()返回 go f() // 创建一个新的goroutine去执行f(),不需要等待 // 阻塞 main goroutine func testDeadLock(c chan int){ for{ fmt.Println(<-c) } } func main() { c :=make(chan int) c<-'A' //无缓存通道, 阻塞 main goroutine go testDeadLock(c) time.Sleep(time.Millisecond) }
-
goroutine 特性
1. 只有在有多个逻辑处理器且可以同时让每个 goroutine 运行在一个可用的物理处理器上的时候, goroutine 才会并行运行。 2. 通道关闭后,goroutine 依旧可以从通道接收数据,但是不能再向通道里发送数据。 3. 能够从已经关闭的通道接收数据这一点非常重要,因为这允许通道关闭后依旧能取出其中缓冲的全部值,而不会有数据丢失。 4. 从一个已经关闭且没有数据的通道里获取数据,总会立刻返回,并返回一个通道类型的零值。
4.3. select
4.3.1. 概述
-
概述:
- select 是类 UNIX 系统提供的一个多路复用系统API, Go 语言借用多路复用的概念,提供了 select 关键字,用于多路监昕多个通道。
-
语法格式:
select{ case 操作1: 响应操作1 case 操作2: 响应操作2 … default: 没有操作情况 }
-
go select使用
- 知识点:
- 知识点:
参考:
4.3.2. 空select
-
概述:
- select 直到 case(在没有default的前提下)中接收到数据时,才会停止阻塞。
- 空
select
将导致程序永久性阻塞(引发死锁);
func main() { select {} }
4.4. 调度器
- 概述:
Go 语言不但有着独特的并发编程模型,以及用户级线程 goroutine,还拥有强大的用于调度 goroutine、对接系统级线程的调度器。
4.4.1. 调度器
4.4.1.1. 概述
-
是什么
1. Go 语言运行时的调度器是一个复杂的软件,能管理被创建的所有 goroutine 并为其分配执行时间。 2. 这个调度器在操作系统之上,将操作系统的线程与语言运行时的逻辑处理器绑定,并在逻辑处理器上运行 goroutine 。 3. 调度器在任何给定的时间,都会全面控制哪个 goroutine 要在哪个逻辑处理器上运行。 4. 调度器是 Go 语言运行时系统的重要组成部分,它主要负责统筹调配 Go 并发编程模型中的三个主要元素, 即:G(goroutine 的缩写)、P(processor 的缩写)和 M(machine 的缩写)。其中的
-
调度器特性 :
1. 调度器不能保证多个 goroutine 执行次序,且进程退出时不会等待它们结束。
-
理解 调度器:
- 所谓的 goroutine 调度,是指程序代码按照⼀定的算法 在 适当的时候 挑选出合适的 goroutine 并放到CPU上去运⾏的过程。
- 涉及的三大核心问题:
- 调度时机:什么时候会发⽣调度?
- 调度策略:使⽤什么策略来挑选下⼀个进⼊运⾏的goroutine?
- 切换机制:如何把挑选出来的goroutine放到CPU上运⾏?
4.4.1.2. 调度策略
-
schedule函数
-
从全局运行队列获取goroutine
-
从本地线程运行队列中获取goroutine
参考:
4.4.2. GPM模型
-
概述:
- M 结构是Machine,系统线程,它由操作系统管理,goroutine就是跑在M之上的;
M是一个很大的结构,里面维护小对象内存cache(mcache)、当前执行的goroutine、
随机数发生器等等非常多的信息 - P 结构是Processor,处理器,它的主要用途就是用来执行goroutine,
它维护了一个goroutine队列,即 runqueue。
Processor 让我们从 N:1 调度到 M:N 调度的重要部分。 - G 是goroutine实现的核心结构,它包含了栈,指令指针,
以及其他对调度goroutine很重要的信息,例如其阻塞的channel。
- M 结构是Machine,系统线程,它由操作系统管理,goroutine就是跑在M之上的;
-
调度示意图:
参考:
4.4.3. 重要结构体
import "runtime/runtime2.go"
-
stack结构体:
stack结构体主要⽤来记录goroutine所使⽤的栈的信息,包括栈顶和栈底位置:type stack struct { lo uintptr // 栈顶, 指向内存的低地址 hi uintptr // 栈底, 指向内存的高地址 }
-
gobuf结构体:
gobuf结构体⽤于保存goroutine的调度信息,主要包括CPU的⼏个寄存器的值:type gobuf struct { // The offsets of sp, pc, and g are known to (hard-coded in) libmach. // // ctxt is unusual with respect to GC: it may be a // heap-allocated funcval, so GC needs to track it, but it // needs to be set and cleared from assembly, where it's // difficult to have write barriers. However, ctxt is really a // saved, live register, and we only ever exchange it between // the real register and the gobuf. Hence, we treat it as a // root during stack scanning, which means assembly that saves // and restores it doesn't need write barriers. It's still // typed as a pointer so that any other writes from Go get // write barriers. sp uintptr // 保存CPU的rsp寄存器的值 pc uintptr // 保存CPU的rip寄存器的值 g guintptr // 记录当前这个gobuf对象属于哪个goroutine ctxt unsafe.Pointer // 保存系统调用的返回值,因为从系统调用返回之后如果p被其它工作线程抢占, // 则这个goroutine会被放入全局运行队列被其它工作线程调度,其它线程需要知道系统 调用的返回值。 ret sys.Uintreg lr uintptr // 保存CPU的rip寄存器的值 bp uintptr // for framepointer-enabled architectures }
-
g 结构体
g结构体⽤于代表⼀个goroutine
,该结构体保存了goroutine的所有信息,包括栈,
gobuf结构体和其它的⼀些状态信息:type g struct { // 具体见源码 }
-
m 结构体
m结构体⽤来代表⼯作线程
,它保存了m⾃身使⽤的栈信息,
当前正在运⾏的goroutine 以及 与m绑定的p等信息,详⻅下⾯定义中的注释:type m struct { // 具体见源码 }
-
p 结构体
p结构体⽤于保存⼯作线程执⾏go代码时所必需的资源,
⽐如goroutine的运⾏队列,内存分配⽤到的缓存等等。type p struct { // 具体见源码 }
-
schedt结构体
schedt结构体⽤来保存 调度器的状态信息 和 goroutine的全局运⾏队列:type schedt struct { // 具体见源码 }
4.5. 包函数
- 相应的
import "runtime"
4.5.1. cpu核心与线程
- 可以使用环境变量 或
runtime.GOMAXPROCS
库函数修改, 让调度器用多个线程实现多核并行;- GOMAXPROCS=2 time -p ./test 来启动程序; //可以明显缩短程序启动到结束的时间;(非cpu时间;)
4.5.2. Goexit 终止执行
- 函数
runtime.Goexit 将立即终止当前 goroutine 执行, 调度器确保所有已注册 defer 延迟调用执行。
4.5.3. Gosched
- 函数
runtime.Gosched
- 作用
- 将当前 goroutine 暂停, 放回队列, 等待下次被调度执行;
参考:
5. 竟态 原子 锁
- 同步goroutine机制
- atomic
- sync 包里的 mutex 类型
5.1. 竟态
- 概述 :
- 如果两个或者多个
goroutine
在没有互相同步的情况下,访问某个共享的资源,并试图同时读和写这个资源,就处于相互竞争的状态,这种情况被称作竞争状态(race candition)。
- 如果两个或者多个
5.2. 原子函数
- 包
import ( "sync" "sync/atomic" )
5.2.1. 原子操作
-
原子操作是什么:
1. 原子操作即是进行过程中不能被中断的操作。 也就是说,针对某个值的原子操作在被进行的过程当中,CPU绝不会再去进行其它的针对该值的操作。 无论这些其它的操作是否为原子操作都会是这样。为了实现这样的严谨性, 原子操作仅会由一个独立的CPU指令代表和完成。 只有这样才能够在并发环境下保证原子操作的绝对安全。 2. Go语言提供的原子操作都是非侵入式的。 它们由标准库代码包 `sync/atomic` 中的众多函数代表。 我们可以通过调用这些函数对几种简单的类型的值进行原子操作。
-
类型:
原子操作类型:
int32、int64、uint32、uint64、uintptr和unsafe.Pointer
类型, 共6个 -
golang 中有哪些原子操作:
有5种,即:增或减、比较并交换、载入、存储和交换
。
参考:
5.3. 锁
5.3.1. golang 锁
-
包
import ( "runtime" "sync" ) 另一种同步访问共享资源的方式是使用互斥锁( mutex )。 互斥锁这个名字来自互斥(mutual exclusion)的概念。
-
其他:
WaitGroup
在调用Wait
之后不能再调用Add
方法;
5.3.1.1. 概述
- 锁:
-
互斥锁(
sync.Mutex
):1. Mutex为互斥锁,Lock() 加锁,Unlock() 解锁, 2. 使用 Lock() 加锁后,便不能再次对其进行加锁,直到利用 Unlock() 解锁对其解锁后, 才能再次加锁. 3. 适用于读写不确定场景,即读写次数没有明显的区别, 4. 并且只允许只有一个读或者写的场景,所以该锁也叫做全局锁。 注: 1. 已经锁定的Mutex并不与特定的goroutine相关联, 这样可以利用一个goroutine对其加锁,再利用其他goroutine对其解锁。 2. 互斥锁只能锁定一次,当在解锁之前再次进行加锁,便会死锁状态, 如果在加锁前解锁,便会报错“panic: sync: unlock of unlocked mutex”
-
读写锁(
sync.RWMutex
): 读多写少的情况, 用读写锁, 协程同时在操作读;1. RWMutex是一个读写锁,该锁可以加多个读锁或者一个写锁, 其经常用于读次数远远多于写次数的场景. 2. 种类 //写锁 func (rw *RWMutex) Lock() 写锁,如果在添加写锁之前已经有其他的读锁和写锁,则lock就会阻塞直到该锁可用, 为确保该锁最终可用,已阻塞的 Lock 调用会从获得的锁中排除新的读取器, 即写锁权限高于读锁,有写锁时优先进行写锁定 func (rw *RWMutex) Unlock() 写锁解锁,如果没有进行写锁定,则就会引起一个运行时错误 //读锁 func (rw *RWMutex) RLock() 读锁,当有写锁时,无法加载读锁, 当只有读锁或者没有锁时,可以加载读锁,读锁可以加载多个, 所以适用于"读多写少"的场景 func (rw *RWMutex)RUnlock() 读锁解锁,RUnlock 撤销单次RLock 调用,它对于其它同时存在的读取器则没有效果。 若 rw 并没有为读取而锁定,调用 RUnlock 就会引发一个运行时错误 (注:这种说法在go1.3版本中是不对的)。 注: 1. 读写锁的写锁 只能锁定一次,解锁前不能多次锁定, 2. 读锁可以多次,但读解锁次数最多只能比读锁次数多一次, 一般情况下我们不建议读解锁次数多余读锁次数
-
参考:
5.3.1.2. 互斥锁 Mutex
- 使用注意:
- 互斥锁变量在加锁后, 不能进行复制, 会复制锁的状态, 在新变量上再次加锁, 会导致死锁;
var mu sync.Mutex mu.Lock() var mu2 = mu mu2.Lock() //
- 互斥锁变量在加锁后, 不能进行复制, 会复制锁的状态, 在新变量上再次加锁, 会导致死锁;
5.3.1.3. 读写锁 RWMutex
5.3.2. Linux 锁
参考: