并发是逻辑上具备同时处理多个任务的能力,并行是在物理上的同一时刻执行多个并发任务。在单核处理器上,它们可以使用间隔的方式切换执行,并行则是依赖多核处理器的物理设备的特性。
并行计算是并发设计的最理想模式。
多线程或者多进程是并行的基本条件,但是单线程也可以用协程做到并发。尽管协程在单线程上通过主动切换来实现多任务并发,但它也有自己的优势。协程上运行的多个任务本质上是串行执行的,加上可控自主调度,所以并不需要做同步处理。
即使采用多线程也未必就能执行并行。Python就因为GIL限制,默认只能并发而不能并行,所以很多时候转而使用"多进程+协程"架构。
通常情况下,用多线程来实现分布式和负载均衡,减轻单进程垃圾回收压力,用多进程(LWP)抢夺更多的处理器资源,用协程来提高处理器时间片利用率。
关键字go并非执行并发操作,而是创建一个并发任务单元。新建任务呗放置到系统队列中,等待调度器安排合适系统线程去获取执行权。当前流程不会阻塞,不会等待该任务启动,去运行时也不保证并发任务的执行次序。
每个任务单元保存除了函数指针、调用参数外,还会分配执行所需的栈内存空间。相比系统默认的KB级别的线程栈,goroutine自定义栈仅仅需要初始化2KB,所以才可以创建成千上万的并发任务。自定义栈采取按需分配策略,在需要的时候进行扩容,最大能到GB规模。
Wait函数:进程退出时不会等待并发任务结束,可以使用通道阻塞,然后发出退出信号。
除了关闭通道以外,写入数据也可以接触阻塞。
如果要等待多个任务结束,推荐使用sync.WaitGroup。通过设定计数器让每个goroutine在退出前递减,直到递归为0时解除阻塞。尽管WaitGroup.Add函数实现了原子操作,但是建议在goroutine外累加计数器,以避免Add尚未执行,Wait已经退出。
GOMAXPROCS:运行时可能会创建多个线程,但是任何时候仅仅有有限的线程参与并发任务的执行,这个数量和处理器的核心数是相等的。可以使用runtime.GOMAXPROCS函数修改,也可以使用环境变量。
如果参数是小于1的,GOMAXPROCS仅仅返回当前设置的值,不做任何调整。
可以使用runtime.NumCPU来显示CPU的核心数。
LocalStorage:gorontine任务无法设置优先级,无法获取编号,没有局部存储(TLS),甚至连返回值都会被抛弃。如果使用map作为局部存储器,建议期间做同步处理,因为运行时会对其进行并发读写检查。
Gosched:暂停,释放线程去执行其他任务。当前任务被放回队列,等待下次调度是恢复执行。该函数很少被使用,因为运行时会主动像长时间运行(10ms)的任务发出抢占调度。只是当前版本实现算法的问题,不能保证调度总是成功的,所以主动切换还有使用场合。
Goexit:立即终止当前任务,运行时确保所有已经注册延迟调用被执行。该函数不会影响其他并发任务,不会引起panic,自然也就无法捕获。
如果在main.main里调用Goexit,它会等待其他任务结束,然后让其他进程直接崩溃。
无论在那一层,Goexit都可以立即终止整个调用栈,与return不同,标准库函数os.exit可以终止进程,但是不会执行延迟调用。
通道:
Go并未实现严格的并发安全。
Go鼓励使用CSP通道,使用通信来代替内存共享,实现并发安全。
通过消息来避免竞态的模型除了CSP,还有Actor。
作为CSP的核心,通道是显式的,要求操作的双方必须知道数据类型和具体的通道,并不关心另一端操作者的身份和数量。可如果另一端为准备妥当,或者消息未能及时处理,会阻塞当前端。
Actor是透明的,不在乎数据类型及通道,只要知道接受者的信箱就行,默认是异步的方式。
通道只是一个队列。同步模式下,发送和接收方配对,然后直接复制数据给对方。如果配对失败,就会置入等待队列,直到另一方出现后才会被唤醒。
异步模式抢夺的是数据缓冲槽。发送方要求有空槽可供写入,而接收方就会要求缓冲数据可以读取。需求不符合的时候同样加入到等待的队列,直到另一方写入数据或者是腾出空的数据缓冲槽之后才会被唤醒。
通道还会被用作事件通知。
同步模式下必须有配对操作的goroutine操作出现,否则会一直阻塞。
多数时候,异步通道有助于提升功能,减少排队阻塞。
虽然传递指针可以来避免数据的复制,但是必须注意额外的数据并发的安全性。
内置函数cap和len返回缓冲器大小和当前已经缓冲的数量,而对于同步通道则都会返回0,可以根据这个特征判断通道是同步的还是异步的。
可以使用ok-idom或者是range模式进行处理数据。对于循环接收数据range更加简洁一些。及时使用close函数关闭通道引发结束结束通知,否则可能会导致死锁。
通知可以是群体类型的。一次性事件使用close效率会更好一些,没有多余的开销。连续或多样性事件,可以传递不同数据标识实现。还可以使用sync.Cond实现单薄或者是广播时间。
对于close或者是nil通道,发送和接收操作都有响应的规则:
1.向已经关闭通道发送数据,引发panic。
2.从已经关闭接收数据,返回已经缓冲数据或者是零值。
3.无论收发,nil通道都会阻塞。
通道默认是双向的,并不区分发送和接收端。但是某些时候,我们可以限制收发操作的方向来获得更加严谨的操作逻辑。
可是使用make创建单向通道,但是没有任何意义。通常使用类型转换来获取单向通道并赋予操作双方。
如果同时处理多个通道,可以使用select语句,它会随机选择一个可用的通道进行收发操作。
如果等全部通道消息处理结束,可以将已经完成通道设置为nil,这样他就会被阻塞,不会被select选中。
即使是同一个通道也会随机选择case执行。
当所有的通道都不可用时,select会执行default语句,如此可以避免seclect阻塞,但是必须注意处理外层循环,以避免陷入空耗。也可以用default处理一些默认的逻辑。
工厂方法将goroutine和通道绑定。鉴于通道本身就是一个并发安全的队列,可用作ID generator。Pool等用途。
可以使用通道实现信号量。
标准库time提供timeout和tick channel实现。
性能:将发往通道的数据打包,减少传输次数,可以有效提升性能。从实现上来说,通道队列依旧使用锁同步机制,单次获取更多数据(批处理),可以改善因为频繁加锁造成的性能问题。
虽然单词消耗更多的内存,但是性能提升非常明显。如果数组改成切片会造成更多内存分配次数。
通道可能会引发goroutine leak,确切的说是指goroutine处于发送状态或者是接受阻塞状态,但是一直未被唤醒。垃圾回收器并不收集此类资源,导致他们会在等待队列里长久休眠形成资源泄露。
通道并不是用来取代锁的,它们有各自不同的用途,通道倾向于解决逻辑层次的并发处理架构,而锁则是用来保护局部范围内的数据安全。
标准库sync提供互斥和读写锁以及原子操作。
将Mutex作为匿名字段时,相关方法必须实现为pointer-receiver,否则会因为复制导致死锁机制失效。
应将Mutex锁粒度控制在最小范围内,及早释放。
Mutex不支持递归,即便是同一goroutine下也会导致死锁。
建议:
1.对性能要求较高的时候应该避免使用deferUnlock。
2.读写并发时,用RWMutex性能会更好一些。
3.对于单个数据写保护,可以尝试使用原子操作。
4.执行严格测试,尽可能打开数据竞争检查。