1、mutex互斥锁互斥锁的实现主要利用到了原子变量可以锁内存总线的机制来对lock变量值进行原子修改,并通过在加锁及释放锁过程中引入内存屏障(加锁引入lfence,释放锁引入sfence),来确保锁临界区资源(Critical Section)能够在不同的CPU之间可见。
mm254jorlff64031555418.png
如图,CPU_0优先抢占到总线的控制权,并在权限范围内对原子变量执行了atomic_long_try_cmpxchg_acquire操作,将其value值设置成自己的线程ID,并通过lfence来确保线程持锁成功后,可以看到锁临界区资源的最新状态。
CPU_1后抢占到总线的控制权,执行CAS操作时将会出错(由于compare不匹配),此时会将当前线程加入到mutex的等待队列中,然后将CPU切换到其他线程进行处理(引发线程的上下文切换)
持有 mutex锁的线程执行unlock操作时会触发原子变量的atomic_long_try_cmpxchg_release操作(相当于sfence加CAS),并对等待队列中排位靠前的线程进行唤醒。
相关API声明
(1) mutex_init(mutex)
初始化互斥锁,对应的用户态函数为pthread_mutex_init
(2) mutex_lock(mutex)
对目标锁进行抢占,对应的用户态函数为pthread_mutex_lock
(3) mutex_unlock(mutex)
释放锁操作,对应的用户态函数为pthread_mutex_unlock
注意mutex的使用是不可重入的,多次调用mutex_lock会导致线程进入死锁。
2、spinlock自旋锁自旋锁的实现与mutex有一定相似之处,都是通过对原子变量执行CAS来决定当前线程是否可以进入锁的临界区。不同的是spinlock在无法获取到锁时不会进入等待,而是在while循环中做不断尝试,为此线程的持锁时间不宜过长,否则很容易造成其他线程空耗CPU的行为。
同时,持有锁的线程更不能进入阻塞或者睡眠,为此kernel还会关闭持锁CPU的preemption功能(借助preempt_disable函数)
相关API声明
(1) void spin_lock_init(spinlock_t *lock)
初始化一个splin_lock
(2) void spin_lock(spinlock_t *lock)
抢占目标锁
(3) spin_lock_irqsave | spin_lock_irq
抢占目标锁的同时禁用CPU的中断,防止中断handler抢锁进入死循环
(4) spin_lock_bh
抢占目标锁的同时禁用CPU的软中断(sofirqd),硬中断依然生效
(5) void spin_unlock(spinlock_t *lock)
释放抢占到的锁
(6) spin_unlock_irqrestore | spin_unlock_irq | spin_unlock_bh
释放锁的同时恢复中断
注意自旋锁并不是公平锁,资源竞争压力比较大时,有可能导致某些线程长时间获取不到锁
3、Semaphore信号量与mutex类似,semaphore也是通过比对一个变量值来决定当前线程是否可以进入锁的临界区,只不过针对变量值的访问,semaphore采用的是spinlock来做同步控制,而mutex则直接基于原子变量。
另外通过semaphore还可允许多个线程同时进入临界区,只有当目标变量值为0时,操作线程才需要进入等待(将CPU调度到其他线程进行处理),直至有其他线程执行up操作将其唤醒。
相关API声明
(1) void sema_init(struct semaphore *sem, int val)
init_MUTEX(name),返回一个semaphore,其val为1
init_MUTEX_LOCKED(name),返回一个semaphore,其val为0
(2) void down(struct semaphore *sem)
信号量的值为0时进入阻塞,否则将信号量的值减1
(3) void up(struct semaphore *sem)
信号量的值加1
(4) void init_rwsem(struct rw_semaphore *sem)
初始化读写信号量
4、seqlocks不同于mutex互斥锁,基于seqlock的读操作并不需要与写操作形成互斥,读写产生冲突时,只需要在读操作一侧发起重试。
3b5ycec5q3c64031555518.png
如图,Thread_1对共享资源执行了读操作,Thread_2对共享资源执行了写操作。
当Thread_1执行步骤(1)到步骤(3)之间时,Thread_2执行了write_seqlock,此时Thread_1将会检测到seqcount前后不匹配,然后发起重试对Resource资源进行再次读取。
由于针对seqcount(类似volatile变量)的读取不需要像原子变量一样去锁内存总线,因此其在读链路上的锁同步开销相对较低。
在写链路上Thread_2获取到seqlock写锁之后,会将seqcount值做加1处理,使其变为奇数。此时read_seqbegin将无法再次进入到锁的临界区内,其会在while循环中做不断重试,直至seqcount变为偶数(Thread_2触发了write_sequnlock,将seqcount再次加1),以此来确保目标Resource在更新期间,不会有新的线程对其进行读取,老的线程虽然可能读取到stale数据,但是会在执行read_seqretry时发起重试。
untx5oieso364031555618.png
相关API声明
(1) read_seqbegin
对seqcount计数进行读取,当其为奇数时说明有写操作存在,此时会进入while循环来不断的发起重试。
(2) read_seqretry
对seqcount计数再次读取,如果其value发生变化,说明共享资源在读取期间产生了变动,需要触发retry逻辑。
(3) write_seqlock
在spinlock锁内对seqcount进行递增,将其变为奇数,然后触发sfence确保变量值能够在其他CPU可见。
(4) write_sequnlock
在spinlock锁内对seqcount进行再次递增,将其变为偶数,然后释放spinlock锁。
注意seqlock的锁获取逻辑并不会引发线程的上下文切换,这点与spinlock一致。
5、Read-Copy-Update以游戏服务器在线升级场景为例,可供选择的运维手段有两种
(1) 强制所有玩家下线,服务升级期间拒绝连接
类似对共享资源执行写操作期间,禁止其他线程读取
(2) 启动一个新版本的服务器,同时老的服务器依然运行(但是不在接收新客户端的连接请求)
当老服务器所有玩家都下线之后,在对其做下线处理
毫无疑问第二种方式对玩家更为友好,Read-Copy-Update便是基于该理念进行设计实现。
majptbyvrjd64031555718.png
如图所示,T1时刻CPU_0对指向node_1的资源指针(hlist_node)做了一份本地拷贝(从堆空间拷贝到栈空间)
T2时刻CPU_1将node_1的next指针引向了NEW,并将修改同步到内存(借助rcu_assign_pointer引入的内存屏障)
T3时刻CPU_0对node_1的next指针进行访问,而此时返回的将是node_2而不是NEW
由此可以看出RCU的实现特点
(1) 读操作与写操作并不互斥
CPU_0对node_1进行读取,相当于是对node_1的hlist_node做了一份本地拷贝(通过READ_ONCE确保操作的原子性),从而与Writer端的写入并不冲突
(2) 读操作访问到的数据有可能不是共享资源最新的状态
比如T2时刻CPU_1已经对node_1的next指针做了修改(指向了NEW),但是CPU_0看到的还是node_2
那么应用层如果想要规避这种情况的发生需要如何处理呢?可以结合seqlock一起使用,比如内核针对dcache的一致性处理。
(3) 写操作如果想要对共享资源进行删除,需要等待所有Reader退出临界区
假如T2时刻CPU_1对node_2执行了删除动作,此时并不能马上对node_2的内存资源进行清理,因为后续CPU_0还会对其进行访问。
为此需要等待所有Reader都退出临界区后(类似老服务器所有玩家都下线),删除操作才能真正执行。
相关API声明
(1) rcu_assign_pointer(pointer, value)
RCU要求针对共享资源的访问必须以指针的方式进行(指针可以保证操作原子性,不会造成数据mashup)
rcu_assign_pointer函数会将pointer指向的数据当做volatile变量来处理(具体可参考内核的WRITE_ONCE宏定义),因此对其执行store操作后,其他CPU能够立刻可见。
同时该函数还会在编译阶段引入内存屏障,来防止编译器做上下文之间的指令重排序。
(2) rcu_dereference(pointer)
rcu_dereference同样会将pointer指向的数据内容当作volatile变量来看待,确保了指针数据的读取不是来自于CPU的store buffer和寄存器,同时避免编译器在编译阶段对指令进行重排序。
方法执行后,相当于对pointer所指向的数据内容做了一份本地拷贝(由堆空间拷贝到栈空间)
(3) rcu_read_lock & rcu_read_unlock
rcu_read_lock用于进入读取临界区(期间会调用preempt_disable来禁用CPU的抢占),以确保node_1在读取期间不会被写入端删除。写入端若要删除node_1,需调用synchronize_rcu等待所有reader退出临界区后才能执行(即执行了rcu_read_unlock调用)
(4) synchronize_rcu
等待每个reader退出了锁的临界区,一种实现方式:等待每个CPU产生一次上下文切换
调用rcu_read_lock进入锁临界区后会禁用CPU的抢占,所以CPU一旦产生线程切换,说明之前的线程已经退出了锁临界区(执行了rcu_read_unlock调用) |