持续更新 Go 语言学习进度中 ......
- GO语言学习笔记-类型篇 Study for Go! Chapter one - Type - slowlydance2me - 博客园 (cnblogs.com)
- GO语言学习笔记-表达式篇 Study for Go ! Chapter two - Expression - slowlydance2me - 博客园 (cnblogs.com)
- GO语言学习笔记-函数篇 Study for Go ! Chapter three - Function - slowlydance2me - 博客园 (cnblogs.com)
Study for Go ! Chapter seven - Concurrency
1. What is concurrency ?
concurrency vs parallelism
并发:逻辑上具备同时处理多个任务的能力
并行:物理上在同一时刻执行多个并发任务
通常说的程序是并发设计的,也就是说它允许多个任务同时执行,但实际上并不一定真的在同一时刻发生
单核处理器以间隔方式执行
并行则依赖多核处理器等物理设备,让多个任务真正在同一时刻执行
并行是并发设计的理想执行模式
多线程或多进程是并行的基本条件,但是单线程也可用协程(coroutine)做到并发,尽管协程在单个线程上通过主动切换来实现多任务并发,但它也有自己的优势。除了将因阻塞而浪费的时间找回来以外,还免去了线程切换开销,有着不错的执行效率。协程上运行的多个任务本质上是依旧串行的,加上可控自主调度,所以并不需要做同步处理
即便采用多线程也未必就能并行,Python 就因 GIL 限制,默认只能并发而不能并行,所以很多时候转用 " 多进程 + 协程 " 架构
各种方式都有各自的使用场景,通常情况下,用多进程来实现分布式和负载平衡,减轻单进程垃圾回收能力;用多线程 (LWP)抢夺更多的处理器资源;用协程来提高处理器时间片利用率
而简单将 goroutine 归纳为协程并不合适,运行时会创建多个线程来执行并发任务,且任务单元可被调度到其他线程并行执行,这更像是多线程和协程的综合体,能最大限度提升执行效率,发挥多核处理能力
只需在函数调用前添加 go 关键字即可创建并发任务
关键字 go 并非执行并发操作,而是创建一个并发任务单元。新建任务被放置在系统队列中,等待调度器安排合适系统线程去获取执行权。当前流程不会阻塞,不会等待该任务启动,且运行时也不保证并发任务的执行次序
每个任务单元除保存函数指针,调用参数外,还会分配执行所需的栈内存空间
相比 默认 MB 级别的线程栈, goroutine 自定义栈初试仅需 2 KB,所以才能创建成千上万的并发任务
自定义栈采取按需分配策略,在需要时进行扩容,最大能到 GB 规模
与 defer 一样, goroutine 也会因 “ 延迟执行 ” 而立即计算并复制执行参数
Wait
进程推出时不会等待并发任务结束,可用通道 (channel)阻塞,然后发出退出信号
除关闭通道外,写入数据也可解除阻塞。
如想要等待多个任务结束,推荐使用 sync.WaitGroup。通过设定计数器,让每个 goroutine 在退出前递减,直至归零时解除阻塞
尽管 WaitGroup.Add 实现了原子操作,但建议在goroutine 外累加计数器,以免 Add 尚未执行,Wait 已经退出
可在多处使用 Wait 阻塞,它们都能接收到通知
GOMAXPROCS
运行时可能会创建很多线程,但任何时候仅有限的几个线程参与并发任务执行,该数量默认与处理器核数相等,可用 runtime.GOMAXPROCS 函数 (或环境变量)修改
(如参数小于1,GOMAXPROCS 仅返回当前设置值,不做任何调整)
Local Storage
与线程不同,goroutine 任务无法设置优先级,无法获取编号,没有局部存储(TLS),甚至连返回值都会被抛弃。但除优先级外,其他功能都很容易实现
使用 map 作为局部存储容器,当前任务被放回队列,等待下次调度时恢复执行
Gosched
暂停,释放线程去执行其他任务。当前任务被放回队列,等待下次调度时恢复执行
该函数很少被使用,因为运行时会主动向长时间(10 ms)的任务发出抢占调度,只是当前版本实现的算法稍显粗糙,不能保证调度总能成功,所以主动切换还有使用场合
Goexit
Goexit 立即终止当前任务,运行时确保所有已注册延迟调用被执行。该函数不会影响其他并发任务,不会引发 panic,自然也无法捕获
如果在main.main 里调用 Goexit,它会等待其他任务结束,然后让进程直接崩溃
无论身处那一层,Goexit 都能立即终止整个调用堆栈,这与 return 仅退出当前函数不同,标准库函数 os.Exit 可终止进程,但不会执行延迟调用
2. Channel
相比 Erlang,golang 并未实现严格的并发安全
允许全局变量、指针、引用类型这些非安全内存共享操作,就需要开发人员自行维护数据一致性和完整性。golang 鼓励使用 CSP 通道,以通信来代替内存安全共享,实现并发安全
通过消息来避免竞态的模型除了 CSP,还有Actor。它们有较大区别
作为 CSP 核心,通道 (channel)是显式的,要求操作双方必须知道数据类型和具体通道,并不关系另一端操作者身份和数量。可如果另一端未准备妥当,或消息未能即使处理时,会阻塞当前端
而 Actor 是透明的,它不在乎数据类型及通道,只要知道接收者信箱即可,默认是异步方式,发送方对消息是否被接受和处理并不关心。
从层次实现上来说,通道只是一个队列,同步模式下,发送和接受双方配对,然后直接复制数据给对方,如果配对失败,则置入等待队列,直到另一方出现后才被唤醒。异步模式抢夺的则是数据缓冲槽。发送方要求有空槽可供写入,而接收方则要求有缓冲数据可读。需求不符时,同样加入等待队列,直到有另一方写入数据或腾出空槽后被唤醒
除传递消息(数据)外,通道还常被用作事件通知
同步模式必须有配对操作的 goroutine 出现,否则会一直阻塞。而异步模式在缓冲区未满或数据未读完前,不会阻塞
多数时候,异步通道有助于提升性能,减少排队阻塞
缓冲区大小只是内部属性,不属于类型组成成分,另外通道变量本身就是指针,可用相等操作符判断是否为同一对象或 nil
内置函数 cap 和 len 返回缓冲区大小和当前已缓冲数量;而对于同步通道则都返回 0,据此可判断通道是同步还是异步
收发
除了使用简单的发送和接受操作符以外,还可以用 ok-idom 或 range 模式处理数据
对于循环接受数据,range 模式更简洁一点
及时使用 close 函数关闭通道引发结束通知,否则可能会导致死锁
通知可以是群体性的,也未必就是结束,可以是任何需要表达的事件
![image-20230307164012213](C:\Users\ASUS\AppData\Roaming\Typora\typora-user-images\image-20230307164012213.png)
对于 closed 或 nil 通道,发送和接受都有相规则:
向已关闭通道发送数据,引发 panic
从已关闭通道接收数据,返回已缓冲数据或 0 值
无论收发,nil 通道都会阻塞
重复关闭,或关闭 nil 通道 都会引发 panic
单向
通道默认是双向的,并不区分发送和接收端。但某些时候,我们可限制收发操作的方向,来获得更严谨的操作逻辑
尽管可用 make 创建 单向通道,但那没有任何意义。通常使用类型转换来获取单向通道,并分别赋予操作双方
不能再单向通道上做逆向操作
同样, close 不能用于接收端
也无法将单向通道重新转换回去
选择
如要同时处理多个通道,可选用 select 语句,它会随机选择一个可用通道做收发操作
如要等全部通道消息处理结束 ( closed ), 可将已完成通道设置为nil。这样它就会被阻塞,不再被 select 选中
及时是同一通道,也会随机选择case执行
当所有通道都不可用时,select 会执行 default 语句,如此可避开 select 阻塞,但须注意处理外层循环,以免陷入空耗
也可用 default 处理一些默认逻辑
模式
通常使用工厂方法将 goroutine 和通道绑定
介于通道本身就是一个并发安全的队列,可用作 ID generator、Pool 等用途
可用通道实现 信号量 (samaphore)
性能
将发往通道的数据打包,减少传输次数,可有效提升性能。从实现上来说,通道队列依旧使用锁同步机制,单次获取更多数据 (批处理),可改善因频繁加锁造成的性能问题
资源泄漏
通道可能会引发 goroutine leak, 确切地说,是指 goroutine 处于发送或接受阻塞状态,但一直未被唤醒,垃圾回收器并不收集此类资源,导致它们会在等待队列里长久休眠,导致资源泄漏
3. 同步
通道并非用来代替锁的,它们各自有不同的使用场景。通道倾向于解决逻辑层次的并发处理架构,而锁用来保护局部范围内的数据安全
标准库 sync 提供了互斥和读写锁,另有原子操作等,可基本满足日常开发需要,Mutex,RWMutex 的使用并不复杂,只有几个地方需要注意
将 Mutex 作为匿名字段时,相关方法必须实现为 pointer-receiver,否则会因复制导致锁机制失效
应将 Mutex 锁粒度控制在最小范围内,及早释放
Mutex 不支持递归锁,即便在同一 goroutine 下也会导致死锁