一 mutex如何保证数据的一致性和正确性
最近突然关于mutex有一个疑问,当我们在开发多线程程序的时候第一个想到的就是用mutex来保护我们的多线程共享变量,保证数据的一致性和正确性,那mutex到底是如何保证的呢?
1 原子访问:避免线程在访问数据对象时,另一线程正在修改它
2 内存可见性:一旦线程修改数据对象,其它线程在修改行为发生之后马上能看见此对象的最新新状态
对于第一点是我之前一直熟知的mutex具有的功能,对于第二点虽然之前了解过关于内存可见性相关的知识但是没有想过mutext也有相关的应用,造成内存不可见的原因主要有2个,一是由于编译器的优化导致,二是cpu的乱序执行和cpu cache(三级缓存)导致。
CPU多级缓存: 缓存一致性、乱序执行优化
二 mutex如何解决内存可见性问题
答案是通过内存屏障(相关内容) :1 揭开内存屏障的面纱 2 什么是内存屏障? Why Memory Barriers ?
关于这个,在书籍<<Programming with POSIX Threads>>的3.4章 Memory visibility between threads:中给出了答案:
三 mutex实现原理
首先我们pthread_mutex_lock并不是立即进行系统调用,而是首先在用户态进行CAS操作,判断其它线程是否已经获取了锁,如果锁被其它线程获取了,再进行系统调用sys_futex(),将当前线程挂起。
Futex是一种用户态和内核态混合的同步机制。首先,同步的进程间通过mmap共享一段内存,futex变量就位于这段共享的内存中且操作是原子的,当进程尝试进入互斥区或者退出互斥区的时候,先去查看共享内存中的futex变量,如果没有竞争发生,则只修改futex,而不用再执行系统调用了。当通过访问futex变量告诉进程有竞争发生,则还是得执行系统调用去完成相应的处理(wait 或者 wake up)。简单的说,futex就是通过在用户态的检查,(motivation)如果了解到没有竞争就不用陷入内核了,大大提高了low-contention时候的效率。
为什么要有futex,他解决什么问题?何时加入内核的?经研究发现,很多同步是无竞争的,即某个进程进入互斥区,到再从某个互斥区出来这段时间,常常是没有进程也要进这个互斥区或者请求同一同步变量的。但是在这种情况下,这个进程也要陷入内核去看看有没有人和它竞争,退出的时侯还要陷入内核去看看有没有进程等待在同一同步变量上。这些不必要的系统调用(或者说内核陷入)造成了大量的性能开销。为了解决这个问题,Futex就应运而生。前面的概念已经说了,futex是一种用户态和内核态混合同步机制,为什么会是用户态+内核态,听起来有点复杂,由于我们应用程序很多场景下多线程都是非竞争的,也就是说多任务在同一时刻同时操作临界区的概率是比较小的,大多数情况是没有竞争的,在早期内核同步互斥操作必须要进入内核态,由内核来提供同步机制,这就导致在非竞争的情况下,互斥操作扔要通过系统调用进入内核态。
更深入的了解跳转至: glibc nptl库pthread_mutex_lock和pthread_mutex_unlock浅析, 在这片文章中可以看到在lock的时候调用了这样一段代码
# define LLL_MUTEX_LOCK(mutex) \
lll_lock ((mutex)->__data.__lock, PTHREAD_MUTEX_PSHARED (mutex))
# define LLL_MUTEX_TRYLOCK(mutex) \
lll_trylock ((mutex)->__data.__lock)
/*
* CAS操作的核心宏,cas操作判断(mutex)->__data.__lock的值是否为0,如果为0,则置为1,ZF=1
* 1.判断是否在多线程环境下
* 2.如果不是多线程环境则直接调用cmpxchgl指令进行cas操作,如果是多线程则需要在cmpxchgl指令前
* 加上lock指令
* 3.如果cas成功则跳到标号18,如果cas失败则调用__lll_lock_wait子程序
*/
# define __lll_lock_asm_start "cmpl $0, %%gs:%P6\n\t" \
"je 0f\n\t" \
"lock\n" \ //!!!!!!lock 指令!!!!!!!!!!!
"0:\tcmpxchgl %1, %2\n\t"
//LLL_PRIVATE为0,所以不会走第一个分支,走第二个分支
#define lll_lock(futex, private) \
(void) \
({ int ignore1, ignore2; \
if (__builtin_constant_p (private) && (private) == LLL_PRIVATE) \
__asm __volatile (__lll_lock_asm_start \
"jz 18f\n\t" \
"1:\tleal %2, %%ecx\n" \
"2:\tcall __lll_lock_wait_private\n" \
"18:" \
: "=a" (ignore1), "=c" (ignore2), "=m" (futex) \
: "0" (0), "1" (1), "m" (futex), \
"i" (MULTIPLE_THREADS_OFFSET) \
: "memory"); \ //!!!!!!指令影响内存!!!!!!!!!!!
else \
{ \
int ignore3; \
__asm __volatile (__lll_lock_asm_start \
"jz 18f\n\t" \
"1:\tleal %2, %%edx\n" \
"0:\tmovl %8, %%ecx\n" \
"2:\tcall __lll_lock_wait\n" \
"18:" \
: "=a" (ignore1), "=c" (ignore2), \
"=m" (futex), "=&d" (ignore3) \
: "1" (1), "m" (futex), \
"i" (MULTIPLE_THREADS_OFFSET), "0" (0), \
"g" ((int) (private)) \
: "memory"); \
} \
})
注意汇编语言中我用感叹号标注的部分":__volatile, __lll_lock_asm_start和"memory""__volatile 表示编译器不要优化代码,后面的指令保留原样,__lll_lock_asm_start中的lock指令和memory,lock用来修饰当前指令操作的内存只能由当前CPU使用,若指令不操作内存仍然由用,因为这个修饰会让指令操作本身原子化,而且自带Full Barrior效果;还有指令比如IO操作的指令、exch等原子交换的指令,任何带有lock前缀的指令以及CPUID等指令都有内存屏障的作用,因此在这里相当于插入了一段内存屏障保证内存的可见性。
volatile、restrict、gcc优化、内嵌汇编的memory修饰符 揭开内存屏障的面纱
四 总结
最后当我们使用pthread_mutex_lock她做了一系列的工作来保证数据的安全性和正确性:总结成一句话就是:mutex = futex + memory barrier,她不仅仅保证了互斥变量的原子性访问还保证了互斥变量在多线程中的内存可见性。