mutex与semaphore之比较--基于Linux实现讨论

时间:2022-03-30 10:00:24
一、mutex VS semaphore类比于bool VS int
在sysv的早期进程间通讯机制中,是没有mutex这个概念的,正如早期的C89中有int类型但是没有bool类型一样,因为mutex只是semaphore的一个特例而已。但是事情的发展往往也是惊人的相似,那就是bool变量在C99中被加入,而在C++开始就作为基本变量而登堂入室,号为正统。反观于pthread库,互斥锁pthread_mutex的地位几乎仅次于pthread线程本身。
为什么会出现这种情况呢?bool类型占用的地址空间和char是相同的,我想不会有哪个编译器真正会置位bool分配一个bit的内存空间,所以空间不是bool崛起的原因。实时上它的原因也很简单,就是为了满足“合理性”,所谓名不正而言不顺,所以bool的存在是对现实世界模型的一个直接映射。在有些情况下,结果只能有两个状态:不是你死,就是我亡。程序中的结构也应该能够且只能够反应这两个状态。比方说,当执行一个if的时候,要么是,要么不是,不可能大概也许可能是。
bool类型是程序自己给自己加的限制,大家也不要觉得限制一定是坏事。事实上,限制是避免个人犯错最有效的方法。我想起以前我刚毕业入职培训的时候,一个讲师讲的一个原本让我印象深刻的故事:在一些工厂里面,机床操作是比较危险的,容易出工伤,比如操作不当可能会造成手指伤残,所以公司会对这些工伤有比较多的赔偿,我们可以假设为40W。这个数字对有些人来说可能不是很多,但是对于每月不到1K的工人有很大的吸引力,所以有些人为了拿到补偿,故意致残。那么解决的办法是什么呢?其实不需要高科技,也不需要微积分,解决的办法就是修改机床的操作流程,当执行这个危险操作的时候,必须双手同时放在一个特定的安全操作区中才能完成,这样即使你想犯错误也无法犯错误。
看一下我们程序中这些自己限制自己的机制。参数传递的时候,对于不需要修改的变量加上const限制,这样即使你不小心对参数赋值了,编译器也会义正词严的告诉你。对于文件的打开,如果是只读的,我们就不要加上“w”权限,同样是为了让制度而不是程序员(操作者)本身的素质来避免错误。这和国家防止贪+污+腐+败(不知道会不会被和谐,安全起见,多加了几个+分隔符)一样,不能依靠公务员自身的素质来防止,而只能依靠制度,从“人治”转为“法制”。
唐僧了这么多,回头看看mutex和semaphore的比较。semaphore同样可以完成mutex的所有功能,但是之后还是单独实现了mutex锁,而事实上,在工程中,mutex的使用和bool的使用一样多,这同样不仅仅是一个“杀鸡焉用牛刀”的问题,而是为了更好的反应现实逻辑和模型,新的内核中同样加入了mutex锁机制,而且内核中加入该机制的时候作者有一篇文档说明了使用mutex的优势,见linux-2.6.21\Documentation\mutex-design.txt。所谓程序的“自注释”有时候就是这么体现的,当使用了mutex的时候,我们就知道这个受保护内容是只能最多有一个持有者的,而即使semaphore的初始值为1,也没有机制本身透露的信息。
二、mutex 相对于semaphore的优势
1、持有者唯一
这一点是mutex本身的一个内置语义,而在这个语义上可以延伸出很多有用的机制。其中最为重要的就是“持有者跟踪”功能。由于mutex的持有者只能有一个,所以我们可以在mutex结构中使用一个指针来记录当前持有者是谁,如果没有人持有,那么该域置空。更进一步,利用这个功能还可以进行错误的检测。当线程执行mutex解锁操作的时候,底层可以判断这个线程是否真正的持有了这个互斥锁。有些时候,线程根本就没有持有该锁就来释放,可能会造成线程的重入,也就是这个锁的基本互斥语义无法满足。
大家可能觉得我又故意耸人听闻了,那么我们就来看一个我之前一片博客(《有时候,goto是唯一选择》)中讨论到的一个例子,就是关于防止线程被杀死导致的互斥锁永远无法被释放的问题
         pthread_cleanup_push((void(*)(void*))pthread_mutex_unlock,(void*)&locker);
         pthread_mutex_lock(&locker);
    大家看上面的代码,如果说在执行pthread_cleanup_push之后,执行pthread_mutex_lock之前,线程被pthread_cancel杀死,那么这里注册的解锁操作将会如期执行,虽然pthread_mutex_lock还没有被执行。该操作之后,这个锁永远处于不一致状态。
大家可能觉得这种事情概率极小,但是正是这些细节决定了一个软件的质量,我相信对于这样的偶发问题,绝大部分的人是不会怀疑到这里的。但是偶发的问题就是这样,我们要证明而不是测试这个代码没有问题,只有减少各个模块偶发的可能性,才能真正将偶发快速定位到合理小、合理准确的范围内。
对于上面的问题,解决方法就是使用mutex提供的错误检测机制,当执行解锁操作的时候,首先判断解锁线程是不是锁的持有者,如果不是,这个解锁直接返回。C库中对于解锁的操作流程
    case PTHREAD_MUTEX_ERRORCHECK_NP:
      /* Error checking mutex.  */
      if ( mutex->__data.__owner != THREAD_GETMEM (THREAD_SELF, tid) 如果当前线程不是锁的持有者,那么此次解锁操作失败,返回权限不足EPERM
      || ! lll_islocked (mutex->__data.__lock))
    return  EPERM;
      /* FALLTHROUGH */

    case PTHREAD_MUTEX_TIMED_NP:
    case PTHREAD_MUTEX_ADAPTIVE_NP: 对于没有加错误检测的锁,无条件解锁,有重入危险
      /* Always reset the owner field.  */
    normal:
      mutex->__data.__owner = 0;
      if (decr)
    /* One less user.  */
    --mutex->__data.__nusers;
这个属性可以通过pthread_mutexattr_settype设置。
作为对比,semaphore无法检测非持有者的释放操作,对于一个优良结构或者说高素质程序员写的程序来说,这不是问题,但是本着从制度上保障正确性的原则,这是一个危险的漏洞。今天废话比较多,再展开一下为什么要照顾到rookie程序员呢?就好像说sourceinsight、ultraedit、eclipse之类的编辑器友好而强大,为什么还有人用notepad呢?因为所谓的成长和强大的过程就是减少对外依赖增加外界对自己依赖的过程。
还有一个就是 锁可以递归重入,也就是一个线程可以连续多次来pthread_mutex_lock来获得某个锁;反过来说,如果没有设置可冲入性,那么一个锁可能会产生自死锁,也就是这个锁是自己获得之后再尝试获得可能会永远挂起,明显地,这个功能也需要能够记录锁的当前持有者。
2、robust锁
另一个优势是当一个mutex锁的持有者线程没有来得及释放自己的锁就溘然长逝,那么内核同样可以代劳唤醒那些等待这些锁的线程,从而让大家可以不因为一个线程的退出而玩不转。关于这一点,实现有些繁琐,但是原理并不复杂,因为这个东西我看的比较早,所以现在没有兴趣再看一遍给大家描述了,所以从略,有兴趣的同学可以搜索一下内核中linux-2.6.21\kernel\futex.c中FUTEX_OWNER_DIED以及glibc中相关加锁操作,关键字就是FUTEX_OWNER_DIED、EOWNERDEAD以及pthread_mutex_consistent.c文件。这里注意一点,当一个互斥锁猝死的时候,内核只负责唤醒一个等待者而不是全部,在内核linux-2.6.21\kernel\futex.c文件中
int handle_futex_death(u32 __user *uaddr, struct task_struct *curr, int pi)

    /*
         * Wake robust non-PI futexes here. The wakeup of
         * PI futexes happens in exit_pi_state():
         */
        if (!pi) {
            if (uval & FUTEX_WAITERS)
                futex_wake(uaddr,  1);
        }
作为对比,当线程退出时,没有人会唤醒semaphore等待者,大家会一直僵持在这里
三、pthread库中sem操作与sysV sem操作对比
前面第二、2节中对semaphore的声讨并不准确,当然如果大家没有自己的判断力的话可能会跟着我一起这么说,这就叫做众口铄金,所以大家养成独立思考的习惯是一个必要条件,也是一个成熟的标志。
在早期的sys V IPC中,这个操作是可以回滚的,但是这个可回滚同样是有代价的,那就是效率,这个效率不仅体现在空间上,最重要的是体现在时间上。在sys V 的信号量操作中,对于信号的操作是通过semget/semop来实现的,这些操作的每一个都需要一个从用户态进入内核的操作,可以想象当加锁解锁操作非常频繁时,这个时间的消耗还是让大家伤不起的。这边就有看官问了:那sem_wait/sem_post就能不进入内核。您还真说对了,从当前的实现来说,大部分时间都是不进入内核的, 只要不存在真正的两个线程来竞争一个锁的地步,如果只有一个线程持有并释放,这个过程中只是简单的用户态判断,不用进入内核
有得必有失,不接收内核监管,取得了效率,同样也失去了后援。当一个进程异常退出的时候,通过sys V 接口操作的信号量是有机会回滚自己来不及归还的信号量,但是pthread库中实现的semaphore没有功能。可能有点抽象,这里再尝试通俗的说一下:信号量是进程间同步机制,进程A和B(当然还可以有C……Z了)都可以P这个信号量,现在假设A P到这个信号之后再做一些操作,不幸的是它躺着也中枪,被人用信号(注意是signal而不是semaphore)杀死了,而它P到的信号量还没来得及释放,那么此时在无辜等待的B将永远也无法醒过来了,它成了A的殉葬任务。
但是sys V是有一个机制可以进行回滚的,内核就像上帝一样,照顾着用户态迷路的羔羊。实现的方法就在semop的一个标志位上,大家看一下semop的第二个参数为一个结构
/* Structure used for argument to `semop' to describe operations.  */
struct sembuf
{
  unsigned short int sem_num;    /* semaphore number */
  short int sem_op;        /* semaphore operation */
  short int  sem_flg;        /* operation flag */
};
其中的sem_flg参数可以接收一个 SEM_UNDO标志,通过这个标志,内核会记录用户做的信号操作的回退操作,当任务异常退出的时候,内核会回滚这些信号操作,从而保证其它线程可以继续运行,回滚操作流程为do_exit--->>>exit_sem,更多内核操作实现信息,请搜索linux-2.6.21\ipc\sem.c中的SEM_UNDO关键字。