Go 多进程编程-同步
多进程编程–同步
文章目录
- 多进程编程--同步
- 同步
- 计数器
文章是书籍: 《 Go 并发编程实战 》的读书笔记.
核心概念是竞态条件, 临界区, 原子操作, 互斥
同步
内核对进程的切换和调度功能使得多个进程能够并发进行而不出错. 不过也仅限于此, 进程之间无法直接沟通.
但是程序员有自己的需求: 我们希望多个进程之间能够协作完成任务, 因此我们需要IPC方法.
在看IPC之前, 先了解通信过程中进程之间可能发生的干扰, 这种干扰主要集中发生在存在共享数据的情况下.
无论是多CPU, 多进程, 还是多线程, 只要他们之间存在着数据共享就一定会涉及到同步问题.
讨论同步问题有利于解决以上三种场景中的许多问题.
计数器
用一个相对简单的应用场景 – 计数器, 作为例子讨论同步问题.
一个计数器, 他由进程A创建, 与进程B共享.
进程A和进程B实际上执行的是相同的程序, 这个程序的任务是把符合某些条件的数据从数据库迁移到磁盘.
程序总是按照固定顺序从数据库查询数据, 然后使用计数器记录下已查询的数据的最大行号, 以此作为依据.
然后是程序的具体执行步骤:
- 读计数器值
- 从数据库查数据, 此处用c指代计数器的值, 一次查10万条数据, 因此用集合 [c, c + 100000) 来表示查询范围.
- 遍历并且筛选出符合条件的数据, 然后将这些数据组合成新的数据集合.
- 将新数据的集合写入到指定目录中的文件内. 文件名称一般有一致的主名称: data, 并且用递增的序号作为后缀, 比如: data1, data2.
- 将计数器值 + 100000, 因此, 计数器的新值就是下一次要查的数据的首行行号.
- 检查数据是否已读取完毕, 读取完毕直接退出, 否则回到 步骤1.
进程A和进程B将会并发运行, 他们会各自循环往复迁移他们认为的下一个数据集合, 知道所有数据全部迁移完毕.
那么这是否会有问题? 那肯定有, 有了解的情况下也知道, 比如读10万个数这个环节中可能会发生上下文切换, 导致程序被打断.
进程A和进程B的运行总是相互穿插在一起, 而且切换的粒度一般是比上面写的几个执行步骤更细碎的.也就是打断问题肯定会发生, 总会产生问题.
不过此处为了清晰, 先假定进程切换的粒度和上面写的步骤有相同的粒度.
以下是在这个假设基础上叙述一种可能的进程调度的过程:
- 内核让CPU运行进程A
- 进程A读取了计数器, 得到了值1, 然后依照此来查询并且筛选数据, 得到了一个新的数据集合.
- 内核认为进程A已经占用了很长一段时间CPU了, 现在将A换出将B换入.
- 进程B开始运行, 他读取计数器的值得到了1, 然后依照此来查询并且筛选数据, 得到了一个新的数据集合. 此处要注意, A和B读到的两个数据集合是完全一致的.
- 进程B将得到的数据集合写入名为data1的文件, 写入完成后关闭了文件.
- 内核将B换出, 让A换入
- A将得到的数据集合写入写入名为data1的文件, 写入完成后关闭了文件.
- A将计数器更新为100001
- 内核将A换出将B换入
- B将计数器值更新为100001
流程示意(自上而下为时间增加, 同一高度时间相同):
那么我们能看到发生了什么问题: A和B做的事是一模一样的, 只用一个进程甚至更节约时间.
原因在于: 同一个进程对计数器的值的存取时间跨度太大了, 计数器在这里起到的只是一个任务进度记录的作用, 没有起到进程间协调的作用.
既然找到这个原因(时间跨度过大的存取操作)了, 那我们把这个时间跨度缩到最小化, 看一看问题能否解决.
此处我们把 步骤(5) 上移到 步骤(2) , 更新后变为了: 让一个进程在读取计数器的值之后立马将他更新.
- 读计数器值
- 将计数器值 + 100000, 因此, 计数器的新值就是下一次要查的数据的首行行号.
- 从数据库查数据, 此处用c指代计数器的值, 一次查10万条数据, 因此用集合 [c, c + 100000) 来表示查询范围.
- 遍历并且筛选出符合条件的数据, 然后将这些数据组合成新的数据集合.
- 将新数据的集合写入到指定目录中的文件内. 文件名称一般有一致的主名称: data, 并且用递增的序号作为后缀, 比如: data1, data2.
- 检查数据是否已读取完毕, 读取完毕直接退出, 否则回到 步骤1.
好的, 更新完了, 不过这样还是错的, 比如一个例子:
内核在A已经读了计数器但是还没有写计数器的时候把A切换到了B.
清晰起见, 此处略去了所有的写入并且关闭文件步骤
可以看到, 两个进程任然是做了一模一样的工作, 和改进之前没有什么区别.
内核只是一个机器, 执行二进制代码而已, 他无法分析语句之间的需求关系, 只是根据预设的算法作进程切换而已.
不过改进还是有点用的, 只要不是在特定的 步骤1 和 步骤2 之间切换A和B, 那么我们的需求就被满足了.
究竟要怎么改, 在说答案前先解释一个概念: 竞态条件(race condition)
竞态条件指多个进程同时访问同一个资源, 彼此之间产生了一种互相干扰的关系.
竞态条件是一种运行时才能找到的问题, 在写代码和测试的时候都很难察觉到.
竞态条件不一定总是会导致问题, 又时候程序没有撞到这个条件那就平安无事, 但是一旦撞到了那差错就非常麻烦了, 尤其是不了解底层运行机制的情况下.
相对其他现代语言, Go有更成熟和先进的并发编程模型, 其目标在于减少程序产生竞态条件的可能性, 京可能多地把复杂地并发处理逻辑埋藏在运行时系统下,进而为程序员腾出精力和时间解决业务问题.
那么再次聚焦到之前的问题, 我们能分析出两种场景的共性: 对于一个进程来说, 我们希望他在某些步骤之间是连续的, 不要被打断.因此, 我们需要保证某些步骤是"一个步骤", 不应当被打断.
我们称一个不能被中途打断(CPU必须保证连续执行完)的操作为: 原子操作(atomic operation).
称只能够被串行化访问或者执行的某个资源或者某段代码为: 临界区(critical section).
在优化后的程序中, 每个进程对计数器的获取和更新操作都需要是原子操作才能得到正确结果, 所以说获取和更新计数器值的代码共同形成了一个临界区.
另外, 所有的系统调用都是原子操作, 其执行是不会被中断的.
原子操作必须由一个单一的汇编指令表示, 并且要得到芯片级别的支持, 当前所有的CPU都提供了对原子操作的支持, 即便是多核CPU也能做到原子操作正确执行, 这也就保证了原子操作能做到绝对的并发安全, 而且会比其他的同步机制快上很多.
原子操作也有个问题: 如果一个原子操作无法结束而且无法中断要怎么办? 这就是内核只提供针对二进制位和整数的原子操作的原因: 原子操作只适用于细粒度的简单操作.
go在CPU和各个操作系统底层支撑之上加入了对原子操作的支持, 具体说来就是标准库 sync/atomic 的一部分函数.
相较于原子操作, 让串行化的代码形成临界区的做法更通用, 保证只有一个进程或线程在临界区之内的做法有学术上的称谓: 互斥(mutual exclusion, 简称为mutex).
实现互斥方法必须确保排他原则(exclusion priciple), 而且这种保证是独立于所有计算机硬件(CPU也在内)的.
现代互斥方法实现方式已经非常多样, 有的停留在理论, 而有的已经是各个操作系统的标配, 信号量作为一种IPC方法就是操作系统的标配.
Go提供 sync
包作为对互斥的支持
CPU也在内)的.
现代互斥方法实现方式已经非常多样, 有的停留在理论, 而有的已经是各个操作系统的标配, 信号量作为一种IPC方法就是操作系统的标配.
Go提供 sync
包作为对互斥的支持