浅谈并发(并行)情况下公共数据的安全与使用效率问题

165 阅读9分钟

对于内存(磁盘)的理解,其实我觉得最合适的还得是理解成一个黑板,而对于同一个教室里的黑板(共享数据:共享内存、数据库、文件等),大家都可以在上面写写擦擦。而擦和写既可以多人同时进行也可以不同时,看你程序的安排,而内存只是一块客观存在的黑板

单一读写操作

那么我们接下来看一下读和写操作在多线程并行的情况下,会出现的以下三种情况:

读-读

我们知道这种情况下,即使并行也没啥问题。

写-写

同时写入

如果两个线程真的同时开始写入,那这其实是一个电路问题,我觉得最后的写入的应该是一个电路叠加的结果(逻辑或)。

比如:A 线程写入 1001,B 线程写入 1100,那么他们同时开始,最后写入到内存中的结果可能是:1101

一先一后,过程中有交叉

如果一个线程先开始,在写的过程中,另外一个线程再开始写入,这个时候需要综合考虑写入数据的数量大小和内存位宽的关系(内存位宽指的是可以单次同时写入或读取内存的 bit 位)。

假如我们内存的位宽是四位,四通道(两个读取通道,两个写入通道),而我们写入的数据大小是一个字节(八位),A 和 B 线程各占一个写入通道。A 写入 0000 1111,B 写入 0000 1111

那么第一次,A 先写入 0000,B 未开始。第二次 A 写入1111,B 写入0000,那么 B 实际写入的结果可能就变成了 1111,接下来再写入剩下的 1111

而最终的结果变成了 1111 1111

总结

当然以上对于 写-写 冲突的分析是我个人的猜测,我也希望有硬件工程师帮我分辨一下我的猜想是否正确。但不过话说回来,写-写 的冲突确实客观存在的,而且最终写入的结果也是无法预测、不符合预期的

读-写

假如我们的共享内存之前的垃圾数据是 1111 0000,现在 A 线程需要写入 0000 1111。当正好写入一半的时候,B 线程来读,那么 B 线程可能就读到一个莫名其妙的数据 0000 0000

解决方案

总的来说,对于同一块数据,在并行的情况下,写-写读-写是存在冲突的

我们前面遇到的问题,主要原因是我们没有保证读写操作的原子性,也就是在我们的一个读写操作中,其他写操作可以插进来干扰。

所以我们需要解决的问题是如何保证读写操作的原子性。而这一点,在逻辑层是无法实现的(程序代码层面),只能由硬件去实现,比如在 node 语言环境中,我们使用 Atomics API 去操作我们的公共数据(Atomics 是通过硬件指令或低级系统调用实现的。它不依赖于用户空间的同步工具,而是在硬件级别保证的。举个例子,当你执行 Atomics.add,CPU 会确保这条指令执行的过程中,其他线程无法打断或并发地修改相同的数据)。

一组读写操作

以上我们针对的是单次读写,这种单次的冲突,一般来说在都是在而且也只能在硬件层面解决。这一块对于我们并发编程的意义不大,不需要关心这一块。

更多的时候,我们关心的是一组读写操作如何保证原子化(不被其他线程干扰)的问题。比如我们现在某一个线程拿到了公共资源,那么我们希望的是我们在使用内存的这段时间,不要被其他线程干扰。在程序上叫做并发编程控制,类似于数据库中的事务。

那么对于一组操作的情况下,我们的 写-写读-写读-读 又会面临什么冲突呢?

首先不用说,读-读 肯定是没有问题的,因为读操作不会改变内存中的数据。接下来我们看一下 写-写读-写

写-写

写-写 的冲突会导致数据被覆盖。还是拿经典易理解的转账来举例:

image.png

读-写

而对于 读-写 的冲突,主要会导致脏读、不可重复读、幻读这三种问题。

脏读

读了写线程的过程数据(过程数据不一定等于最终数据,如果不等于,那过这个程数据就无效,也就是所谓的脏数据)。

不可重复读

对同一条数据读多次,读到的结果不一致(因为有写线程在改这个数据)。

幻读

对同一个结果集(范围条件相同)读多次,读到的数据条数不一致(因为有写线程在增删数据)。

解决方案

解决方案也很简单,那就是加锁,而根据加不同的锁,达到对资源的不同占有程度,我们可以分为以下三种:串行化S 锁与 X 锁MVCC

串行化

解决并发冲突问题最简单直接有效的方式就是加锁。

在访问到共享资源的时候将其锁住(独占),等到用完再释放锁,这就是一种串行化的方式。

S 锁与 X 锁(细化锁对资源的占用程度)

上面的这种方式确实有效地解决了并发读写冲突问题,但是还有没有优化空间呢?显然是有的。

我们知道 读-读 是不冲突的,而 写-写读-写 才是冲突的。那么在上面的方式中,假如我们多个线程都是执行读的操作,那么就会造成不必要的阻塞。

想要优化掉这种情况,那就是对锁进行分类,分成共享锁(S 锁)和排他锁(X 锁)。这样可以进一步细化我们对资源的占有程度。

接着只要对读操作加 S 锁,而对写加 X 锁即可。

通过 S-S 锁之间非阻塞,而 X-XX-S 锁之间阻塞的这种控制机制,实现 读-读 的并行。

MVCC

通过前面 S 锁和 X 锁的使用,我们实现了 读-读 并行,但是 读-写 以及 写-写 还是阻塞的,那么还有没有优化空间呢?

首先 写-写 的冲突肯定是无法避免的,关键在于我们还能不能优化掉 读-写 的冲突,让 读-写 也能并行呢?

其实解决方案还是有的,那就是 MVCC(多版本并发控制)。这其实是一种用空间来换并发性(时间)的做法,也就是对于同一份数据我们维护多个版本。我们对于写操作,总是基于最新版本去写,写完并且生成一个最新版本。而对于读操作则是基于历史最新稳定版(已提交)本去读。这样就实现了读和写内存地址的不同(读写分离),解决了 读-写 冲突问题。

总结

先提一个比较难理解的问题:我们的 MVCC 机制有没有解决我们开始提到的单一读写操作中的 读-写 冲突,实现读写并行呢?

答案其实是即使在单一操作的情况下,MVCC 机制也做到了读写并行

那疑惑可能就来了,前面不是说单一操作的读写冲突在程序层面解决不了,只能在硬件层面去解决吗?

其实是这样的:对于同一块内存的 读-写 冲突上层应用确实无法解决,但是 MVCC 进行多版本控制,而读和写操作的是不同的版本,也就是不同的内存地址,那么自然是不存在 读-写 冲突,也就实现了读写并行。

我们知道解决并发冲突的方式就是加锁,而根据锁对资源的占用程度,我们又分了 串行化S 锁与 X 锁 以及 MVCCMVCC 是一种目前位置优化到最好的一种方式,但也存在需要占用更多的内存资源的缺陷。

最后我们再提一下关于对待锁的两种态度:乐观锁悲观锁,这也算是我们并发编程的两种思想。

我们知道在实际的开发过程中,一组操作可能是 单纯地只读又读又写单纯地只写 这三种情况。但具体哪些情况多,哪些情况少,我们不得而知,得看具体的业务情况。

悲观锁思想认为:冲突一定会发生,因此在访问数据之前,必须对数据加锁以防止其他线程干扰。 乐观锁思想认为:冲突不会发生,在操作数据时,不需要对数据加锁。

那么在乐观锁的情况下,不加锁操作数据,当如果冲突真的发生了,我们该怎么办呢?难道他就不管了?

其实不是,他通过一种通过多维护一个版本号数据的方式来实现冲突发现。也就是说,在拿到公共资源的时候,同时获取和记录当前公共资源版本号。当需要执行写操作时,需要检查之前读取时的版本号与当前公共资源的版本号是否一致。如果一致,则写入成功,公共资源版本号加一,表示资源有更新;如果版本号发生变化,说明数据在此期间被其他线程或进程修改,操作失败,需要回滚重试。

所以至此我们也知道了,悲观锁适用于写操作多的场景,乐观锁适用于读操作多的场景。

参考