基于mmap共享内存在文件中实现既可用于多进程又可用于多线程的无需持久化的并发HashMap,支持多进程并发rehash,我们就叫它SharedHashMap吧!
使用mmap把文件内容映射到进程的虚拟地址空间,在这块虚拟地址空间中实现一个HashMap
- 每个进程都会使用mmap把Nodes文件做为内存池映射到自己的虚拟地址空间,内存池由一个userspace spinLocker进行保护,任何一个进程检测到内存池已使用完时,都会发起一次内存池的扩容操作,扩容首先会调用ftruncate对Nodes文件扩展到当前大小2倍,然后使用mremap进行重映射,完成一次内存池的扩容,只有第一个发起扩容的进程才能初始化内存池,后来的进程直接扩容后就可以使用内存池,扩容期间可以对map,进行get,update操作,进行set,delete操作将会自旋,因为当set,delete时要访问内存池,获取内存池的锁,而此时内存池正在扩容,锁被发起扩容的进程lock着。当一个进程进行扩容后,其它进程要及时,感知到这种变化,自己也发起一次扩容,否则在一个未及时扩容的进程的HashMap中访问已扩容后的进程插入的从扩容后的虚拟地址空间中分配出去的节点,未扩容的进程将崩溃
- 当进程重启后所有的数据将变的无法访问,因为所有的数据的操作,都是基于之前进程的虚拟地址空间的,对于map中的每一个node,在不同的进程中有不同的虚拟地址,当内存池进行扩容后,已经从内存池中分配出去的node都将失效,扩容后再通过扩容前的地址去访问扩容前的node,进程将收到一个段错误,然后崩掉,因为扩容前的虚拟地址空间有可能已不存在于进程可访问的虚拟地址段中,因此在内存池扩容后,每一个进程都要修正自已的map中每一个的node的虚拟地址,为了解决这种问题,在从内存池分配node时只能分配相对地址,每次获取对象时使用当前的Nodes文件被映射的首地址+相对地址去访问node
- 由于扩容导致虚拟地址的改变,在上一行代码中可以正常访问的节点,在下一行代码中可能已经失效,因此代码调试不是像多线程环境中那样顺利,要详细考虑每一行代码中的数据是否已经失效,否则进程将崩溃,一种解决办法就是扩容时强制从上一次映射的虚拟地址开始映射
- 多进程并发rehash问题,多进程并发rehash是一种协作关系,由于多进程通信及SharedHashMap设计实现原因,实现并发rehash不是那么容易,所以在此会有一进程专门负责rehash,rehash进程会把Slots文件当做哈希槽mmap到自己的虚拟地址空间,redis使用渐进式rehash,ConcurrentHashMap使用多线程并发rehash, SharedHashMap使用全量rehash,一次性把原map中的数据移动到扩容后的map中,在数据量大的情况下可能会产生一小段时间的不可用,但rehash在整个map的生命周期中并不常见,如果我们能预估数据集大小rehash可能永远不会发生,rehash进程与其它进程使用同一把UserSpace读写自旋锁,通过获取写锁来取得rehash的机会,因此在进行rehash过程中其它进程都将因获取不到读锁而自旋,在rehash后其它进程要及时感知到已经rehash了要进行一次哈希槽的切换,在进行操作map前其它进程会获取到共享的读锁让rehash进程无法进行下一次rehash。因为进程1,2,3可能率先完成哈希槽的切换,此时插入了大量数据,而rehash进程可能会又发起多次rehash,而进程4,5,6可能还正在进行第一次的哈希槽的切换
- 锁的粒度控制,jdk1.8的ConcurrentHashMap,已经不再使用分段锁,采用每个哈希槽一把锁的及CAS的方式,进一步减小锁的粒度,SharedHashMap,也采用一个哈希槽一把UserSpace 自旋锁,在高并发访问情况下了可根据编译参数使用读写自旋锁进一步减少锁冲突,当hash冲突足够小时,map的访问速度已经很快了,而一个自旋锁在不开启死锁检测的情况下只需一个字节就可以了,而读写自旋锁需要4字节,在开启了死锁检测,由于字节对齐,在32位系统下两者都会使用8字节
- 自旋锁导致cpu长时间空转问题,任何一进程在在进行初始化map时都会获取读锁,去初始化哈希槽的锁,同时任一进程在初始化内存池时,其它进程如果要申请释放Node都将自旋,如果map的哈希槽比较大,内存池比较大,每次的扩容后其它进程将有一段长时间的自旋,对自旋锁进行改造,当自旋一定次数后,将升级为互斥锁,给每个哈希槽分配一把互斥锁不太现实,这里的互斥锁,只是为了解决进程的park与unpark问题,并不是真的要获取锁,锁是在用户空间实现的,在此将使用Java Synchronizor及AQS的park/unpark底层实现方式来解决,进程的park与unpark,因此将会使用信号量,用来 park/unpark相关的进程,当拥有锁的进程释放锁时会判断是否有进程sleep在此信号量上,如有则唤醒正在睡眠在此条件变量上的所有进程
- 一个进程进行了哈希槽或内存池的扩容后,其它进程如何感知?在此使用 kafka 解决controller脑列问题,及consumerGroup解决因rebalance后已被踢出consumerGroup的consumer提交无效offset问题所使用的方式,kafka每轮controllor选举后都会产生一个epochId下发到每个broker,consumerGroup的每次rebalance,都会产生一个generatorId下发到consumerGroup中的每个consumer,而generatorId与epochId都是自增的。在SharedHashMap中 每一个进程的本地都有一个local expandId,在共享内存中都有一个共享的expandId,当扩容进程完成扩容后会对local expandId自增同时更新共享的expandId,每个进程在操作map及内存池前都会检测local expandId与共享的expandId是否一致,如不一致将进行扩容操作,完成扩容操作后更新local expandId与共享expandId一致
- Java的ConcurrentHashMap的get是一个无锁操作,当一个线程在get,一个线程在put时,get线程可能在此次get并不能拿到新插入的Node,也可能拿不到最新update的值,当一个线程在remove时get线程可能会拿到一个已删除的Node,当在SharedHashMap中去除锁时由于没有引用计数,当一个进程在get,一个进程在del,刚好get访问到一Node,del同时删除了此Node,而此Node被回收后又立即分配出去插到另一个slot中导致get时出现slot跳越问题,为了解决此问题可在Node回收时置next值为0(SharedHashMap中0代表NULL)解决此种问题,但会造成链表访问不完整,另一种方法就是对回收到内存池中的Node设置闲置期,如采用链表尾插入法进行Node回收,并设置回收时的时间只有脱离闲置期的Node才能从内存池中再分配出去,否则扩容内存池,但此种方法很难设定出一个合适的闲置时间,要彻底解决这种问题就是采用Java 解决非线程安全的容器被并发修改的fail-fast机制,在此SharedHashMap并不会抛出 ConcurrentModificationException 异常,这在SharedHashMap中当做一种正常现象,只用来告诉正在遍历的进程,链表已改变可能有Node加入,也有可能有Node删除,更新。为了实现这种机制需要在SharedHashMap的slot中加入一个无符号共享的char 类型的changes变量,每次对slot,进行写操作时都会加1,当到256时回到0,在链表的一次遍历中,如:changes从26增加到256再回到26,并被正在遍历的进程检测到,这应该是一种小概率事件,由于字节对齐的原因在此使用1字节与4字节都会占用相同的空间,索性就用4字节了。当get进程对slot进行遍历时先读取changs到本地副本,在遍历过程中为了不漏掉新插入的Node,不误拿到已删除的Node,保证拿到访问进程中update的Node,访问每一个Node前都先判断本地副本与slot中的changs中的是否一致,遍历结束后还要再判断一次,如不一致则从头开始重新遍历,这两种方法只能去除slot锁而并不能去除与rehash进程共用的读写锁
- 如何去除与rehash进程共用的读写锁?去除get方法中的rehash相关的扩容方法的调用,get方法不参与检测发起slot切换,就无需使用读写锁了,但此处存在一个问题,就是如果一个进程一直不进行,set,del,操作,只进行get操作那么此进程就一直得不到最新的slot,get将会一直使用一个过期的slot,最终导致无法获取到新插入的Node,在此get方法将先检测slot切换条件,如果条件成立才去获取读锁,完成slot切换,而set,del方法却不能这样,因为在执行set,del的任何时候,都有可能发生rehash,要保证,set,del操作的是切换后的slotB而不是rehash前的slotA,而这样会造成get取不到已经被rehash进程移动到slotB中的Node
class MetaData{
public:
unsigned int curMovingIdx;
int rehashing;
int status;
unsigned int slots;
int expandId;
char curUsingSlotFile[256];
char curExingSlotFile[256];
int rehashDone;
};
- 如何去除与rehash进程共用的读写锁?当rehash进程正在进行rehash时, rehash进程会把slotA中的Node移动到slotB中,这时set,del,get,进程要感知到正在rehash,从而也把slotB映射到自己的虚拟地址空间,在set,get,del时先操作slotA如果失败就操作slotB,以此保证数据的一致与完整性,当在rehash完毕时,rehash进程要在共享内存中设置rehashOver标识,让其它进程进行slot切换,而其它进程也要在共享内存中设置全部完成switchOver标识slot切换完成,当rehash进程检测到switchOver时才能进行下一次rehash,这里有一个问题就是当一个进程在rehash过程中进行了一次操作后就再也没进行过操作,会导致无法完成switchOver,因此我不得不去除rehash进程,让每个进程都能rehash,实现如ConcurrentHashMap同样的rehash方式,多进程并发rehash,任何进程一旦检测到正在rehash就立马加入一起rehash直到rehash完毕才能返回继续进行set,get,del操作,这时我们要换一种策略来进行多进程间的rehash协作,去除rehashOver,switchOver标识,用rehashDone==0来表明所有参与rehash的进程都已完成rehash,当调用doRehash函数时会对rehashDone原子增1,当在rehash过程中检测到已完成rehash时对rehashDone原子减1,然后循环判断rehashDone是否是0,如果是0则代表,所有参与rehash的进程都已完成了rehash,此次rehash结束,可以进行清理切换工作了,删除各自己的slotA,切换到各自的slotB,由于slot锁是所有进程共享的所以不能重复删除,只有识别到curMovingIdx==slotsA的进程才能进行锁的清理,这个进程同时会设置rehashing=0(第一个发起slot扩容,rehash的进程会设置rehashing=1),其它的进程识别到rehash结束的条件是curMovingIdx>slotsA||rehashing==0
- 对于多进程并发rehash时,进程奔溃问题导致无法完成rehashDone问题解决,当一个进程参与rehash后会对rehashDone原子增1,当进程在rehash过程中被误杀或奔溃退出时,会导致rehashDone永远无法为0,进而导致死锁。在共享内存实现一RehashProcessQueue队列,当rehash进程加入时把自己的pid放入此队列,当自己完成rehash后,在对rehashDone原子减1后从队列中移除自己的pid,因此只要在循环检测rehashDone时,顺带检测RehashProcessQueue中的进程是否都alive,否则清空RehashProcessQueue,置rehashDone为0
- 当我试着去除get方法中的读写锁时,我对之前的读写锁的使用进行了调整,把 rehash过程中持有的写锁,换成了读锁,把读锁加在了扩容slotB哈希槽时使用,因为多个进程在set时可能同时检测到了需要扩容哈希槽,rehash过程中持有读锁,可以使多个进程在并发rehash中,可以阻止先完成rehash的进程,再次发起下一轮扩容哈希槽。由于去除锁后没有保护,导致变量被多个进程同时修改删除,进程频频奔溃,这简直是一个灾难,当我终于解决这个问题后又出现了死锁问题,由于锁带有membar的功能,当去除锁后,在修改一个多进程共享的变量时,不像使用锁时那么容易,修改一个变量,首先要从内存中读取这个变量,而这个变量可能被加载到了多个cpu核心的cachline中,当一个运行的进程使用这个共享变量时它所运行的cpu核心的cacheline中的数据可能不是最新的,这时就需要使本地这个变量所在的cacheline无效,以便重新从内存取得其它cpu核心修改的最新数据,cpu提供了读内存栏栅来解决这种问题,当修改完一个变量后要把数据同步到内存并告诉其它cpu核心,它们的cacheline中的数据已经无效了,cpu提供了写内存栏栅来解决这种问题,因此在没有锁的情况下,在修改一个变量前后要自己加上内存栏栅,cpu之所以会提供内存栏栅是由于,cpu在修改完一个被多个cpu cacheline cache的变量时需要征求所有拥有此变量的cpu核心的同意才能修改,所以要向其它cpu广播这个请求,在等待所有cpu响应期间,发出请求的cpu会暂停,因此cpu搞了一个storeQueue,invalidQueue来异步完成数据的修改与发布,又提供了membar来flush这两个队列使队列的请求立马完成。这也不是一个问题,加呗。但由于进程死锁的出现让我以为,是共享的变量没有及时同步到各个cpu核心导致,以致于我在接下来的一个星期的时间里,时分疲倦的我像无头苍蝇一样,在代码里到处加membar,这时我把所有的共享变量全部加上了volatile因为我已经开始怀疑,我那个狗屁“顿悟”的正确性了,期间我有过几次想要放弃去除get中的锁,但最后在星期5的晚上也就是2019.08.16,这天下班的路上我对自己说,不行这个周末一定要调通,回到出租房的后我放下了电脑,然后从上社走到了华景,途中经过大王鸭货由于下雨路上堵车现在已经7:30,有点饿就买了一个鸭头,一个鸭爪,一个鸭翅,到了华景后叫了一碗羊肉面,并加了面,今天这碗面煮的特别的久,期间我发现煮面的小伙子还了玩手机,等了10几分钟的面终于煮好了,真是个实称的小伙子,真的是加了3块钱的面,多到我看了心慌,我赶忙,把面从碗里捞了一大半出来,放到装鸭骨头的朔料袋中,吃了一口实在是难吃,面都被他给煮坨了,真是十分的难吃,算了回去吧,又TM开始下雨了,广州的雨说来就来说走就走像风一样,无耐单身狗的我出门从来都不会带伞,只好在门口坐了一会,谁知遇到两个哥们一个喝醉了,坐在地上,靠着墙,另一个坐在旁边的凳子上,没过一会地上那哥们开始吐了,吐了一地,流了一身,这还没完,旁边那哥们看这哥们吐了,竟被恶心吐了,真是受不了了哦,等不到雨停了,冒着雨也要回去了,可能是吃了一碗羊肉面和淋了雨的原故吧!回到出租房我特别的精神,完全没了以前昏昏欲睡的感觉,没一会儿就发现导致的死锁的原因,只因一个共享变量被另一进程提前修改而导致在rehash过程中slotA越界访问,去获得一把不存在的锁,而导致了死锁,今天果真是个好日子
- 关于进程的park与unpark,由于多个进程会共用一把读写自旋锁来保证哈希槽的扩容及初始化与rehash的正确性,当已经启动了多个进程,系统已经正常的运行了一段时间,哈希槽已经扩容的很大了,这时由于数据量增加又启动了更多的进程加入,或正在运行的个别进程崩溃重启,会遇到初始化哈希槽过久,长时间持有写锁导致其它进程长时间自旋导致cpu空转的问题,在此使用Java 锁的底屋实现方式来解决cpu空转问题,Java的锁是由AQS实现,AQS使用LockSupport类解决线程的park与unpark问题,并在park线程时把线程挂到用户空间锁的等待队列,因此在unpark时可以选择要唤醒那个线程,从而可以实现公平锁。在linux下JVM使用pthread_mutex与pthread_cond,来实现LockSuppport,解决线程的park与unpark,在此我也使用pthread_mutex与pthread_cond来实现多进程的park与unpark也可以使用信号量,由于pthread_mutex与pthread_cond 在APUE中只被提及了是可以用于多进程间的同步,但并没有用例 ,网上也仅有几个pthread_mutex的例子,根本没有pthread_cond的,于是我按着APUE介绍在多进程中使用pthread_mutex与pthread_cond,但却在pthread_broad_cast时出现了死锁,挣扎了几天仍无法解决,于是我去掉了pthread_mutex/pthread_cond,因为我根本不需要,AQS那么多的功能,我只需在进程在获取用户空间锁时当自旋了一定次数后就自己把自己挂起,当一个进程释放了用户空间锁时就唤醒所有被挂起的进程就行了,于是我只使用了一个信号量,完成了多进程的park与unpark,当信号量被创建时值为0,当第一个进程来对信号量执行P操做时就会被park,当另一个进程释放了自旋锁后就会判断当前有无进程被park,如有则对信号量执行V操做会unpark一个进程,当进程被unpark后也会判断当前有无进程被park,如有则对信号量执行V操做会unpark一个进程,如此所有被park的进程都会被unpark
1:当一个进程在获取不到用户空间的自旋锁而park自已时,就对waiters变量原子增1然后对对信号量执行P操做,由于此时信号量的值为0,所以当前进程无法获取信号量被挂起,后来的进程同样被挂起,
2:当一个进程释放用户空间的锁时,对信号量执行V操做
3:此时挂起并睡眠在此信号量上的进程中的一个会被OS唤醒
4:由于我们要唤醒所有的进程,所以当一个进程被唤醒后会判断,waiters是否大于1,如是则原子减1,对信号量执行V操做,此时另一个进程会来到步骤3
5:如不是则直接返回,此时信号量为0,这样当最后一个进程被唤醒时所有的进程都因为获取了一次信号量而被unpark
- 关于内存分配器的实现,SharedHashMap的内存分配器只支持一种类型size对象的分配与回收,并不是一种通用的内存分配器,可以适配任何size对象的分配。在此借鉴linux内核1.0.0对物理内存的分配与回收实现,内存分配器在创建时会创建Nodes文件,并使用fallocate扩展到固定大小,使用mmap映射到进程的虚拟地址空间,mmap会返回虚拟地址的首地址,然后对这块内存进行初始化,其实就是用一个静态链表把空闲的内存块串起来,分配时从链表开头取一个链表节点,回收时就再插到链表的开头,可以在O(1)的时间开销完成内存的分配与回收