用户空间自旋锁实现的思考

我顿悟,不论这个结论是否正确,我还是要以它为指导思想来写点代码,因为我真的想写点并发场景下的东西来验证我这七年来对编程及操作系统的思考

于是我又重启了去年六七月分停止的并发编程大业,打开了虚拟机,重启了乌班图,准备大干一场

可是啊!原本以为自己对并发编程已经有点了解了,但实际写代码出现问题时还是会一时摸不着头脑,让人信心大措,我准备在userSpace通过gcc原子操作和共享内存实现一个既可以用于多线程又可用于多进程同步的spinLock,同时对linux提供的信号量和互斥量进行封装实现一个既可用于多线程的又可用于多进程同步的锁,对程序员提供一种便利的锁的使用方式,匿名锁用于同一个进程中多个线程的同步,命名锁既可用于多个进程间的同步又可用于多线程间的同步,同时加上因进程奔溃导致的死锁检测与清除

 

我开始按自己的想法和对锁及linux内核对共享内存实现的理解,编写代码,并进行测试,起初,一切进行顺利,每次测试运行都能得到一个期望的值,但代码改着改着就出问题了,多个进程对共享内存中的一个整型变量通过userSpace spinLock进行同步,同时进行10亿次自增操作时,总是得不到想要的结果,后来连直接使用原子自增也得不到正确的结果了

 

几经排查欣然发现由于刚开始测试启动的进程个数比较少,使用一个4字节的int类型做为共享变量就可以保存自增结果了,后来开到了十几个进程,int类型溢出了,就换了一个8字节long long类型,问题出现在这里?我的虚拟机是32位的,当使用gcc提供的原子操作__sync_fetch_and_add((long long*)ptr,1),在32位的系统下操作一个64位8字节的long long类型时,编译器产生了一大串汇编指令使用进位加法在32位4字节寄存器中来实现对这个64位的8字节进行加1操作,而不是一条原子指令

是这样?

如图在32位系统下对一个4字节int类型变量进行原子自增,编译器产生如下汇编代码

第8-10行 生成main函数调用的栈帧

第12行 代码把栈中变量count的地址放入eax寄存器

第13行 lock硬件级锁,锁住总线,防止其它cpu核心及其它cpu,同时执行指令,addl直接对eax寄存器中地址指向的值加1,由此完成一次原子加1操作

第14行 把0放到eax寄存器做为默认返回值

第15行 清理栈帧此为stdcall

第16行 从栈中弹出ip指令到ip寄存器中返回完成此次函数调用

 

 

如图在32位系统下对一个8字节long long类型变量进行原子加1,编译器产生如下汇编代码

这一长串代码使用了多个寄存器辅助来完成对long long类型变量进行加1,大眼一看这么长一串代码肯定不是原子操作啊,但细分析,它却实现了原子操作所保证的语义

第47行代码原子操作lock cmpxchg8b(我们常说的CAS)把计算结果ecx,ebx中的值与开始这轮计算开始时栈中变量count值eax,edx(x86是小端所以,eax中是低32位,edx中高32位)做比较,如果相等把计算结果8个字节放到栈中,esp中存放的地址处即为count赋值,如果不相等则jne 8048509至第40行代码,从栈中取count的最新值分别为高低4字节并保存到edx,eax寄存器中,进行下一轮计算,如此反复直致成功,可以理解在代码级别实现了一个自旋的乐观锁

 

这也解释了为什么我把int类型换成long long类型后程序慢了很多很多

 

由此可以发现long long类型并不是导致结果不正确的原因

 

然后我在代码中到处加membar也没用,我对所有的变量进行检查,是否有共享的变量,被直接引用但是没有使用volatile修饰,翻来翻去就发现多进程在共享内存中,共享过两个变量,一个变量是实现spinlock用到的一个4字节int,一个是用来做累加结果用到的一个8字节long long,这两个变量,确实是没使用volatile修饰,但这两个共享变量是以指针的形式对外提供可见的,编译器应该无法感知到它的存在,所以就不可能把它做为一个变量优化到寄存器中,再者我8字节,32位cpu一个寄存器才4字节

 

问题到底出现在了那????

只有看下反汇编代码了,看下编译器到底生成了什么样的代码,到底有没有把变量优化到寄存器

其实是咱写了一个BUG啊!

 

 

关于多进程SpinLock进程异常崩溃退出,代码bug导致进程正常退出未释放锁导致的死锁解决,同样也适用于多线程代码bug导致线程正常退出未释放锁导致的死锁

 

在C++中可利用RAII通过对象的生命周期来解决资源泄漏导致的问题,如内存泄漏导致的oom,锁未释放导致的deadlock

  1. 线程崩溃,整个进程都玩完了不用考虑,操作系统会回收整个进程已拥有的资源

 

  1. 在使用RAII的情况下由于异常,或正常return,编译器都会保证对象的析构函数被调用,从而释放资源,而调用longjmp在从一个函数栈桢跳到另一个函数栈帧并不会调用当前栈帧中临时对象的析构函数

 

  1. 进程崩溃使用文件锁,文件锁是与文件关联的,进程无论以何种方式退出总会从内核的exit函数退出做最后的清理工作并释放此进程拥有的文件锁

 

  1. 进程崩溃使用信号量,只需对信号量指定SEM_UNDO标志即可

 

5.多进程使用userSpace SpinLock进程异常崩溃退出,代码bug导致进程正常退出未释放锁,多线程使用userSpace SpinLock或pthread_mutext,代码bug导致线程正常退出未释放锁,则需要进行死锁检测与清除

 

 

死锁检测清除方法

  1. 对锁对象增加owner成员变量,在进行lock时为其赋值,值为进程pid,或线程tid,unlock时赋值为0
  2. 每创建一个锁就将其注册到DeadLockMonitor中
  3. 主进程对子进程/线程进行wait,pthread_join,当有任务退出后进行死锁检测
  4. 遍历所有注册的锁,取出其owner判断其是否存活或是否是退出任务的pid/tid
  5. 如owner已dead则解锁

 

 

 

关于无父子关系的多个进程并发创建锁对象,重复初始化锁,导致多个进程同时获取锁的问题解决

多进程锁对象存在于共享内存中,无父子关系的多个进程在创建锁时,锁的内部会通过共享内存把锁文件内容映射到自己的虚拟地址空间,然后对锁进行初始化,此实现可运行在有父子关系的多进程中,父进程创建锁对象,子进程可不创建锁对象直接使用父进程锁对象的副本即可

在有父子关系的多进程环境中子进程按序依次创建,而在无父子关系的多个进程环境中,子进程可能被同时创建,存在并发,创建锁的情况,

对于重复初始化锁的解决,在锁对象的构造函数中,去除锁的初始化动作,锁的初始化改为在创建共享内存对象时,初始化锁文件时使用fallocate初始化锁文件大小的同时初始化文件内容为0

对于并发创建锁对象,在锁对象的构造函数中使用文件锁进行同步即可

 

SharedMem(char *filePath,int size=sizeof(Type)){

              this->size=size+sizeof(int);

              strncpy(this->filePath,filePath,256);

              int fd=open(filePath,O_RDWR|O_CREAT);

              if(fd==-1){

                     printf("open file %s error:%m\n",filePath);

                     exit(-1);

              }

              struct stat status;

              if(fstat(fd,&status)<0){

                     printf("stat file %s error:%m\n",filePath);

                     exit(-1);

              }

              if(status.st_size<this->size){

                     if(fallocate(fd,0,0,this->size)<0){

                            printf("fallocate file %s error:%m\n",filePath);

                            exit(-1);

                     }

              }

       obj=(Type*)mmap(NULL,this->size,PROT_READ|PROT_WRITE,MAP_SHARED,fd,0);

              if(obj==NULL){

                     printf("mmap file %s error:%m\n",filePath);

                     exit(-1);

              }

              ref=(int*)obj;

              obj=(Type*)((char*)obj+sizeof(int));

              incRef();

       }

 

在多进程环境下锁文件被提前重复删除的问题解决

在有父子关系的多个进程中,父进程创建锁,多个子进程得到锁对象的副本,当一个子进程退出时,锁对象副本的生命周期结束,锁对象的析构函数被调用,析构函数会调用mumap卸载共享内存,删除锁文件。由于此时其它子进程还未退出,不能删除锁文件,对共享内存使用引用计数进行管理,当使用共享内存类创建共享内存对象时,把其申请的内存大小加4个字节做为引用计数,返回的内存地址为((char*)ptr+4),

在锁对象的构造函数使用原子操作进行引用计数加1,在析构函数中使用原子操作对引用计数减1,当引用计数为0删除锁文件

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章