缺陷的背后(二)---互斥锁申请后未释放异常退出

序言

       某日,开发哥哥一如往常的在线上发布版本,kill掉应用程序后启动新程序,程序启动后,应用程序就一直阻塞在某处,于是版本回退,重启旧版本,应用程序依旧阻塞在某处。pstack查看进程栈后发现,原来是第一次被kill掉的程序是运行在临界区时被kill的,而代码又有bug,在申请锁的时,未对这种情况“占着资源的死去”进行处理,导致后续程序再申请锁时只抛异常,而不释放资源。

      那这个bug测试应该怎么模拟呢?一般程序常用哪些锁呢?针对进程/线程锁什么场景下需要锁呢?加锁的影响是什么呢?锁的粒度应该如何控制呢?怎么测试“锁”呢?本文为说明这类问题,分以下结构进行总结:


     一:测试的“经典缺陷”
     二:锁的常用基础知识
        1、Linux多进程同步方式对比
        2、常见的锁类别   
        3、互斥锁和Mysql锁的使用场景
        4、加锁的影响和锁的粒度
     三:锁的测试方法和策略

一、测试的“经典缺陷”

      缺陷定义 :  以上描述场景,实际上是锁的一个经典的使用场景。程序在获取了临界资源后异常退出,临界资源一直处于加锁状态,其他进程/线程申请锁程序未正常处理就会导致阻塞,甚至死锁等待

      测试模拟 :  测试的时候,必须清楚锁的使用各个场景和异常场景,如果只是通过无计划的大数据压测,重现该场景的可能性很低,因为该类异常必须在临界区kill进程,因此测试必须使用GDB打断点,运行到临界区的断点后,kill进程才能模拟。然后再次启动程序,pstack查看进程栈是否阻塞

      缺陷修复 :  这类缺陷主要产生的问题就是,后续进程在申请锁时,如果出现了“锁被已死进程”占有后,应该怎么让系统回收锁的问题。本质上这个问题就是进程间互斥锁回收问题。

     下面是导致产生bug的代码片段:            

void ProcessMutex::lock()  throw(CException){  
    if( pthread_mutex_lock(m_pMutex) != 0){
            throw CException(ERR_LOCK_CREATE, "Failed to lock mutex!", __FILE__, __LINE__);}}  

       修复方法:

       第一步:设置强健属性为 PTHREAD_MUTEX_ROBUST_NP。只要在互斥锁初始化时调用pthread_mutexattr_setrobust_np设置支持回收机制。

ProcessMutex::ProcessMutex(const char* path, int id) throw(CException): m_ShMem(path, id, sizeof(pthread_mutex_t), true)
{
    m_pMutex = (pthread_mutex_t *)m_ShMem.address();

    // 设置互斥量进程间可共享
    if(m_ShMem.isCreator())
    {
        pthread_mutexattr_t mutex_attr;
        pthread_mutexattr_init(&mutex_attr);  
        
        pthread_mutexattr_setpshared(&mutex_attr, PTHREAD_PROCESS_SHARED);  
        pthread_mutexattr_setrobust_np(&mutex_attr, PTHREAD_MUTEX_ROBUST_NP);
        pthread_mutex_init(m_pMutex, &mutex_attr);
        
        pthread_mutexattr_destroy(&mutex_attr);
    }
}

  第二步:捕获获取锁返回异常EOWNERDEAD,调用pthread_mutex_consistent_np完成锁owner的切换工作即可。

void ProcessMutex::lock()  throw(CException)
{  
    int iErrno = pthread_mutex_lock(m_pMutex);
    if( iErrno != 0)
    {
        if ( iErrno == EOWNERDEAD )
        {
            pthread_mutex_consistent_np(m_pMutex);
        }
        else
        {
            throw CException(ERR_LOCK_CREATE, "Failed to lock mutex!", __FILE__, __LINE__);
        }
    }
}  

二 :锁的常用基础知识

       下面的介绍,主要是基于测试过程中遇到的各种锁的一些归纳小结,有些其他更高级的锁暂未接触到,后面有接触到再更新本文章吧~~~

   1、Linux多进程同步方式对比

       在进行多进程开发的时候,经常会遇到各种进程间同步的场景,Linux多进程同步机制的性能和功能均有较大差异 ,一般使用以下4种方式: 

  • GCC内建原子操作
  • 基于共享内存的mutex(pthread mutex)
  • POSIX信号量
  • fcntl记录锁

        从功能上分析:原子操作< mutex < 信号量 < 记录锁。原子操作只支持有限的几种整数运算;mutex只支持加锁和解锁两种状态;信号量则支持计数;记录锁功能最为丰富,能支持读写锁、区间锁、多次加锁一次释放、进程退出自动释放等功能。

        那性能呢?简单的测试方法:程序分别启动1~5个子进程,在共享内存中存放一个int整数,每个子进程对其自增1M次,总计时间,程序运行5次取均值。(时间单位为毫秒),结果性能排名是:原子操作 > mutex > 信号量 > 记录锁。记录锁甚至在单进程的情况下性能都低于mutex在5个进程下的表现,到多进程的时候性能比其它同步操作低了一个数量级以上。结果如下:

                                                        

    2、常见的锁类别   

     第一类:unix内核级别锁。这类锁经常使用,针对于多进程或者多线程的程序在运行的过程中,有时会出现公共资源抢占使用的情况就会使用到这类锁,这类锁常用的分以下4类:

  • 互斥锁:mutex;获取锁失败后会休眠,释放cpu。
  • 自旋锁:spinlock;遇到锁时,占用cpu空等。
  • 读写锁:rwlock;同一时刻只有一个线程可以获得写锁,可 以有多线程获得读锁。
  • 顺序锁:seqlock; 本质上是一个自旋锁+一个计数器。

       互斥锁实际上是一种变量,在使用互斥锁时,实际上是对这个变量进行置0置1操作并进行判断使得线程能够获得锁或释放锁。 提供两种获得锁方法,常用的是pthread_mutex_lock: 

       pthread_mutex_lock:如果此时已经有另一个线程已经获得了锁,那么当前线程调用该函数后就会被挂起等待,直到有另一个线程释放了锁,该线程会被唤醒。

       pthread_mutex_trylock:如果此时有另一个贤臣已经获得了锁,那么当前线程调用该函数后会立即返回并返回设置出错码为EBUSY,即它不会使当前线程挂起等待

       而互斥锁的底层实现,一般使用swap或exchange指令,这个指令的含义是将寄存器和内存单元中的数据进行交换,这条指令保证了操作lock和unlock的原子性。

       第二类:文件锁:FileLock;防止多进程并发;是一种文件读写机制,在任何特定的时间只允许一个进程访问一个文件。

       第三类:Mysql锁。根据锁类型:共享锁(读锁),排他锁(写锁);根据锁策略:表锁,行锁,间隙锁 ;根据锁方法:悲观锁,乐观锁

     3、互斥锁和Mysql锁的使用场景

          针对互斥锁,主要在以下三个常见场景经常使用:

  • 数据共享:主写,子读 主线程定时加载DB/文件/队列内的数据,子线程读取数据。
  • DB句柄:主读,子读 DB句柄在主线程/全局变量内定义,子线程需要更句柄来更新数据

  • 非线程安全的API使用: SHA256签名,Rsa256 Localtime ->localtime_r

           针对Mysql锁,主要分事务内和事务外:

  • 事务中,使用排他锁 select...for update 只有指定主键,MySQL 才会执行Row lock
  • 非事务,乐观锁 where 前置条件

     4、加锁的影响和锁的粒度

  • 不必要的加锁,影响性能:之前接入银行接口时,接口协议使用了SHA256算法,这个算法由于开发哥哥的不正当使用,在加密时做了加锁的操作,直接导致性能从800TPS下降到400TPS。

        错误的使用:

 unsigned char* digest = SHA256((unsigned char *)strUnSign.c_str(), strUnSign.size(), NULL);

       正确的使用,这样后面使用该算法时,openssl库的锁能保证并发的可靠性

unsigned char digest[SHA256_DIGEST_LENGTH] = {0};
SHA256((unsigned char *)strUnSign.c_str(), strUnSign.size(), digest);
  • 高并发不加锁访问临界资源,直接导致程序运行结果与实际不符合。

三:锁的测试方法和策略

     测试方法:

  • 多进程/多线程调试:线程调试常用命令:break <linenum> thread <threadno>, info thread,thread <threadnum>, set scheduler-locking on,thread apply all bt。
  • 编译特殊版本:1、进程/线程获取锁后,打印日志并sleep N 秒,其他线程获取时,都会阻塞。 2、pstack,strace查看。
  • 代码走读:1、资源使用环境确认,判断是否需要加锁(多读或者多写)
  • 压测:大数据压测

         其中第一个和第二个方法主要是针对已知加锁的地方进行测试;第三个和第四个方法用于确定是否需要锁

     测试点:

  • 锁有效性测试:获取到锁后,其他线程/进程获取锁是否在正常等待,等待多久?
  • 获取锁后程序异常退出测试:获取到锁时任务如果挂掉了,锁还未被释放,后续再请求分配锁时是否会死锁?

        

原文地址:https://www.cnblogs.com/loleina/p/10546198.html