std::mutex是一个用于实现互斥访问的类,其具备两个成员函数——lock和unlock
锁的底层实现原理
锁的底层实现是基于原子操作的,这些原子操作是由指令支持的,因为单个指令是不能被中断的
一些与锁的实现有关的原子指令为:
待补充
互斥锁与自旋锁的区别
自旋锁
当前线程在使用获取自旋锁时,如果锁已经被其他线程持有,那么当前会一直忙等待,直到其他线程释放自旋锁。自旋锁会导致CPU时间的浪费,只适用于指令较少的临界区
互斥锁
如果临界区较长,那么使用互斥锁就不合适了,这时需要一种方法,使得当前当前线程获取锁失败时可以陷入睡眠,在锁被其他线程释放时,有机会被唤醒
在调用std::mutex 的lock函数时,如果其他线程已经持有锁,那么当前线程陷入睡眠,让出CPU资源,使得CPU可以去执行其他任务
在调用std::mutex 的unlock函数时,释放锁,同时唤醒由于调用std::mutex 的lock获取锁失败而陷入睡眠的进程
std::mutex与std::lock_guard和std::unique_lock
std::mutex通常搭配std::lock_guard/std::unique_lock使用,以便自动管理加锁、上锁操作
std::lock_guard/std::unique_lock区别在于
- std::lock_guard在性能上要稍微比std::unique_lock好一点
- std::unique_lock更加灵活,具有可以主动调用lock/unlock,而std::lock_guard在构造函数中加锁,在析构函数中解锁
- 使用条件变量时,必须用std::unique_lock
std::mutex源码实现
class mutex : private __mutex_base // 基类为__mutex
{
// ...
void
lock()
{
int __e = __gthread_mutex_lock(&_M_mutex); // _M_mutex在基类中。 实际上调用glibc c库的pthread_mutex_lock
// ...
}
void
unlock()
{
__gthread_mutex_unlock(&_M_mutex);
}
// ...
}
class __mutex_base // mutexd的基类
{
protected:
typedef __gthread_mutex_t __native_type;
__native_type _M_mutex = __GTHREAD_MUTEX_INIT; // 初始化宏
constexpr __mutex_base() noexcept = default;
// ...
}
typedef pthread_mutex_t __gthread_mutex_t; // _M_mutex的原始类型就是pthread_mutex_t
#define __GTHREAD_MUTEX_INIT PTHREAD_MUTEX_INITIALIZER // 初始化宏是glibc库的初始化宏
从以上代码中可以看到,std::mutex实际上是对glibc pthread mutex实现的一层封装
pthread mutex实现与futex机制
pthread_mutex_t结构体的定义为
typedef union {
struct __pthread_mutex_s {
int __lock; // !< 表示是否被加锁,是否存在竞争
unsigned int __count; //!< __kind代表可重入锁时,重复上锁会对__count进行递增。
int __owner; //!< 持有线程的线程ID。
unsigned int __nusers;
int __kind; //!< 上锁类型。
int __spins;
__pthread_list_t __list;
} __data;
} pthread_mutex_t;
pthread_mutex_t.__data._lock的值有3
-
0, 表示还没有进程/线程获得这把锁
-
1, 表示有进程/线程已经获得了这个锁
-
2, 当lock值已经为1,且又有进程/线程要获取这个锁时,将lock置为2,表示发生了竞争
需要注意的是,__data._lock是一个int整型,int类型的数据数写并不是原子的,需要使用原子操作test-and-set
或 compare-and-swap (CAS)
保证对int数据读写的原子性
pthread_mutex_t.__data._kind的是一个32位的整数,库的设计者将这个32字节分成几个部分来提通不同的分类方式
-
其中的第1到第7位,代表一个锁的类别。
-
PTHREAD_MUTEX_TIMED_NP
是mutex的默认类型,它是非递归的,这也是我之后要着重分析的锁类别。而PTHREAD_MUTEX_RECURSIVE_NP
则表示可重入锁。其他类别不展开了(能力也不够)
-
-
它的第8位表示该锁是用在进程间同步还是线程间同步,通常情况下我们在线程同步中使用mutex,此时只需要将mutex声明在全局数据段即可;但如果是进程间的同步,则需要先开辟一个共享内存,将mutex放入共享内存中,然后不同进程才能操作同一个mutex。
futex机制
glibc nptl引入了futex机制(fast userspace mutex,快速用户空间锁),futex机制是用户空间和内核空间协作完成的,futex机制根据pthread_mutex_t.__data._lock的值进行优化。
pthread mutex的底层实现依赖于futex机制
pthread_mutex_lock实现
对于PTHREAD_MUTEX_TIMED_NP类型的mutex,pthread_mutex_lock最终会调用到如下代码
#define __lll_lock(futex, private) \
((void) \
({ \
int *__futex = (futex); \
if (__glibc_unlikely \
(atomic_compare_and_exchange_bool_acq (__futex, 1, 0))) \
{ \
if (__builtin_constant_p (private) && (private) == LLL_PRIVATE) \
__lll_lock_wait_private (__futex); \
else \
__lll_lock_wait (__futex, private); \
} \
}))
这里传入的futex即pthread_mutex_t结构体中的__data._lock
atomic_compare_and_exchange_bool_acq是一个CAS操作,可以简写为 CAS(lock, new_val, old_val), 能够达成的效果是,如果old_val == lock,则将new_val赋值给lock,并将old_val返回
这里可以看到,如果__futex值为0,表示没有其他线程加锁,atomic_compare_and_exchange_bool_acq直接返回0,此次加锁操作可以直接在用户态完成,这样的话,就避免了调用系统调用函数时的压栈操作,以及由用户态和内核态之间相互切换的上下文切换操作;若__futex值不为0,则需要进一步调用__lll_lock_wait_private或者__lll_lock_wait,调用futex系统调用进行后续操作。
pthread_mutex_unlock
pthread_mutex_unlock最终会调用到如下代码
#define __lll_unlock(futex, private) \
((void) \
({ \
int *__futex = (futex); \
int __private = (private); \
int __oldval = atomic_exchange_rel (__futex, 0); \
if (__glibc_unlikely (__oldval > 1)) \
{ \
if (__builtin_constant_p (private) && (private) == LLL_PRIVATE) \
__lll_lock_wake_private (__futex); \
else \
__lll_lock_wake (__futex, __private); \
} \
}))
如果__oldval==1,表示只有当前线程持有了锁(这里有一个假设是pthread_mutex_unlock是在正确的情况下使用的,即当前线程已经获取了锁),此时可以直接返回
如果__oldval>=2,表示有其他线程在这个锁上阻塞,由于当前线程释放了锁,此时需要唤醒其他调用了pthread_mutex_lock但被阻塞的线程
futex机制
所有的futex同步操作都应该从用户空间开始,首先创建一个futex原子变量。其中以pthread_mutex的锁操作过程为例,state表示其持有和竞争状态,0为无人持锁,1为有人持锁但无人竞争,2为有人持锁且有竞争。
-
当线程A准备lock持锁,发现state==0,则直接通过CAS(Compare and Swap)原子操作将state=1,表示持有该锁就结束了lock过程,整个过程并不需要内核参与,非常高效;
-
当线程B到来准备lock持锁,发现state==1,锁已经被别人持有,于是使state=2表示有竞争,然后通过futex_wait(&state) 操作陷入内核,内核会根据用户空间地址&state创建一个等待队列,然后将B加入到&state的等待队列中,将B休眠;
-
当线程A退出临界区unlock释放该锁时,发现state==2意识到有人竞争,会将state=0,然后通过futex_wake(&state) 陷入内核来唤醒等待&state的线程B。线程A返回用户空间继续执行,退出unlock流程;
疑问:在将state置为0和调用futex_wake之间,如果有其他线程获取了锁,futex_wake如何保证正确性。(应该是futex_wake内部会再次检查一遍state的值。)Chatgpt答案:即使其他线程(如C)在futex_wake前抢锁,被唤醒的线程(如B)仍会通过CAS安全地重试。futex_wake的语义保证唤醒的线程会重新检查状态,避免因竞态导致错误。参考nptl/pthread_mutex_lock.c,确实有CAS重复检查 -
线程B从futex等待队列中移除,并加入调度程序的就绪队列,等待调度。得到调度后返回用户空间重新判断state==0,于是重复步骤1;(疑问,如果有三个线程ABC都在抢占互斥锁呢?)
对于第四个步骤的疑问,如果有三个线程ABC都在抢占互斥锁呢?假设按照如下步骤:
-
state==0
-
A获取锁成功,state=1
-
B尝试获取锁失败,state=2
-
C尝试获取锁失败,state=2
-
A解锁,发现state==2,将state置为0,调用futex_wake(这里是否会同时唤醒BC?假设只唤醒B),唤醒B
-
B被调度,发现state==0,将state置为0,成功获得锁
-
B解锁,发现state==1,直接返回(C还在睡眠呢)
futex底层实现
futex内部维护着一个等待队列,该等待队列使用哈希表结构,用来保存获取互斥锁失败而陷入睡眠的任务(task_struct)
futex_wait(uaddr,val,abs_time) 流程:
-
计算 futex 对应的 key,获取 key 对应的哈希表链表。
-
获取哈希桶链表自旋锁,如果 *uaddr == val 返回错误给用户态(为什么?)。
从这里可以看到,获取哈希桶自旋锁后会再检查一次uaddr的值,确保进程睡眠之前,锁仍然是不可获取到的。获取哈希桶自旋锁和重新检查uaddr值的操作保证了进程睡眠之前,锁是没有释放的
-
否则将当前任务状态改为 TASK_INTERRUPTIBLE,并将当前任务插入到 futex 等待队列,释放哈希桶链表自旋锁,然后调度器重新调度。
-
当该线程从睡眠中被唤醒时,例如超时或者被wakeup,做相应处理后,返回用户态。
futex_wake(uaddr,nr_wake) 流程:
-
计算 futex 对应的 key,获取 key 对应的哈希桶。
-
获取哈希桶的自旋锁,遍历这个哈希桶上的优先链表,找到key匹配的任务,说明该任务在等待 futex,将当前任务添加到唤醒队列 wake_q 中,如果达到了 nr_wake 个,则退出循环。
-
释放哈希桶自旋锁,唤醒队列 wake_q 中每一个任务。