C++11实现一个读写自旋锁-3(顺序锁 )

上一篇文章介绍了一种写者不会被被排在它后面的读者后来居上抢先占有锁,让写者“饿死”的读写锁的实现方案,它保证了写者在同读者竞争锁时的公平性。本文再介绍一种读写锁,它能保证写操作的最高优先级,即写者进行写操作时不受读者的影响,如果没有其它写者同时与它竞争的话,它随时都可以进行写操作,极大地保证了更新数据时的及时性。它是一种特殊的读写锁,一般称为顺序锁。

我们知道,当使用读写锁同时对数据进行读、写操作时,它们是互斥的,然而在使用顺序锁时,读、写操作之间并没有使用锁来进行互斥,写操作不受读操作的影响,不过,读操作会受到写操作的影响,若读操作期间写操作进行了更新,读操作会无效。它的机制是这样的:当写者进行写操作的时候,会无视读者的存在,直接进行写操作,但是读者在读操作完成之后,要检查读操作期间数据有没有被修改过?如果被修改了,就放弃已经读取的数据,重新读取数据,也就是说读者总是假定它所读取的数据是正确的,是一种乐观读锁。

实现原理

顺序锁的实现原理是这样的:使用一个计数器来累加写操作的次数,只有当这个计数器的值为偶数时,才允许进行写操作或者读操作,即读者和一个写者可以同时进行;在写操作时,先让这个计数器加 1,这样它就变成了奇数,表示有一个写者正在修改数据(即持有写锁)。当写者完成写操作之后,再次让这个计数器加 1 变成偶数,说明写者已经完成了写操作(即释放写锁)。读者在读取时把计数器的值保存下来,读完数据之后,比较当前计数器的值和保存的原值是否相同。如果相同,则说明在读操作期间,数据没有被更新过,成功地读取到了数据;否则,则说明读到了脏数据,就丢弃数据,并重新开始读。总之,写者之间通过竞争计数器为偶数时来获准写操作,而读者通过确保计数器在读取数据期间没有发生变化,来保证读到正确的数据。

实现代码

下面是顺序锁的类定义:

#include <atomic>

class seq_lock final {
public:
	int read_entry();
	bool read_validate(int seq);
	void write_lock();
	void write_unlock();
	
	seq_lock() = default;
	seq_lock(const seq_lock &) = delete;
	seq_lock &operator=(const seq_lock &) = delete;

private:
	atomic_int counter{0};
};

数据成员 counter 表示计数器,它是 atomic_int 类型,初始值为 0,只有写者才能改变它。当写者准备写操作时,先让它加 1 变成奇数,当写完之后,再次加 1,又变成偶数。可见,当它为奇数时说明有写者正在进行写操作,为偶数时说明没有正在进行写操作。尽管 counter 可以一直累加下去,却不用担心会产生溢出,从下面的实现代码可以看出,counter 并不比较大小,只是判断是否为奇偶数和是否相等,这个和是否是正负数没有关系,即使是溢出后从正数变成负数也能正常工作。

1、进入读操作

int seq_lock::read_entry() { // 乐观读 
	while (true) {
		int seq = counter.load(std::memory_order_relaxed);
		if (seq & 1) { // 奇数时,自旋等待
#ifdef X86
			asm("pause"); // 让CPU暂停
#endif
		} else { // 直到偶数才返回,说明写操作已经结束了
			return seq; // 返回计数值,校验读结果时再使用
		} 
	}
	std::atomic_thread_fence(std::memory_order_acquire);
}

该函数用于读者检查是否可以进行读操作,它先检查计数器的值,如果是奇数,说明此时正有写者在更新数据,数据是不安全的,有可能状态没有全部更新完,此时读者不应该进行读操作,而是应该继续自旋等待。当计数器的值为偶数时,说明没有写者正在进行写操作,数据是状态一致的,可以进行读取操作,读者就从自旋中退出,并把计数器的值作为返回值。尽管读者有自旋的动作,好像是在申请锁,它只是在检查计数器的奇偶值,成功后并没有持有锁和释放锁的操作,不影响写者的操作。

在自旋等待时,如果是x86环境,可以使用x86提供的pause指令,以降低CPU的耗电量和优化性能,具体分析,可参考文章《自旋锁的实现及优化》

最后,std::atomic_thread_fence(std::memory_order_acquire)语句用来保证,在后面读取数据时,读操作不会发生在读 counter 操作的前面,以保证所读数据是正确的。它同 seqLock::write_unlock() 的 std::memory_order_release 内存序形成 release-acquire 语义。

不过,当 counter 为偶数时,只是说明在这一时刻没有写者正在修改数据,只能说明此时读操作有可能成功,当然也有可能读到脏数据,数据是否读取成功,还得需要下面的函数进一步检查。

2、检查是否脏读

bool seq_lock ::read_validate(int seq) {
	return counter.load(std::memory_order_relaxed) != seq; // 如果不是原来的值,说明共享数据有更新 
}

该函数非常简单,检查 seqLock::read_entry() 取得的序号 seq 和计数器当前值是否相等。如果相等,说明在读操作期间,没有写者对数据进行了更新,读取数据是正确的。如果不相等,说明有新的写操作正在进行或者已经结束了,数据已经被修改了,读者读到的是脏数据,应该放弃本次读取的数据,重新开始读取。显然在不相等时,如果计数器当前值是奇数,说明有写者正在写,如果仍是偶数,说明在读操作期间,有写者完成了一次或多次更新。

当然,此方式也有误判的时候,如果数据已经读取完毕了,写者前后脚紧接着开始进行写操作,即读者在调用counter.load()的时候,恰好一个写者开始准备写操作,只是把计数器加1变成奇数,还未开始修改数据,此时读者会认为是数据是脏数据,只能回退,重新开始,直到当前写操作完成,再次重新读取数据。虽然是误判,作废了一次正常的数据,但它是把正常数据误判为脏数据,不会引起错误,只是成功读取数据的时间晚了一点而已。不过,它也有正面意义,即虽然已经读到了正常数据,但是新数据正在更新中,干脆放弃就本次数据,等写者更新完之后,读取更新后的数据。

因为调用本函数时,数据已经读取到本地了,没有内存顺序的要求,故可以使用memory_order_relaxed类型的内存序。

3、申请写锁

void seq_lock ::write_lock() {
	while (true) {
		int seq = counter.load(std::memory_order_relaxed);
		if (seq & 1) { // 如果是奇数,说明有另一个写者持有锁,写操作正在进行
#ifdef X86
			asm("pause"); // 让CPU暂停
#endif
		} else { // 如果是偶数,让计数器加1变成奇数,表示写者持有锁 
			if (counter.compare_exchange_strong(seq, seq+1, std::memory_order_acquire)) {
				break;
			}
		}
	}
}

写锁是一个自旋锁,只有当计数器为偶数时,才允许进行写操作。如果是奇数,说明此时还有别的写者正在进行写操作,该写者就自旋等待直到计数器为偶数;在写操作修改数据之前时,先让这个计数器加 1,这样它就变成了奇数,意味着有一个写者正在修改数据,别的写者此时要进行写操作时,就得等待计数器再次变成偶数。

同样,write_lock同write_unlock()形成release-acquire语义,保证写者进行修改时读到的数据是前一个写已经正确修改后的。

写写之间是有锁的,在写操作之前,写者要调用此接口申请写锁,返回后就可以进行写操作了。

4、释放写锁

void seq_lock::write_unlock() {
	counter.fetch_add(1, std::memory_order_release);
}

释放写锁非常简单,让计数器加 1,我们知道写者申请到锁后,计数器的值变成奇数,此时加 1 之后又变成了偶数,表示写操作已完成,其它读者或者写者则可以进行读取或者修改了。这里对 counter 要使用 memory_order_release 内存序,因为在调用 seqLock::write_unlock() 之前有数据更新的操作,这里保证这些更新发生在 couter.fetch_add() 修改之前,同时 seqLock::read_entry() 和 seqLock::write_lock() 中的 memory_order_acquire 的内存序又保证读取数据的操作发生在 counter.load() 的后面,这样就能保证读到的肯定是已经更新完的数据。

使用说明

顺序锁只有写锁,没有读锁,它的应用方式同前面介绍的读写锁不一样,有必要说明一下使用它的步骤和注意事项:
1、定义一个顺序锁对象。

seq_lock seqlock;

2、读线程代码样板:
由于没有读锁,只要 seq_lock::read_entry() 返回了,说明此时没有正在进行的写操作,读者就可以读取数据了,在读完数据之后使用 seq_lock::read_validate() 判断是否读成功,如不成功则回退重读,直到成功为止,因此,该过程是一个循环结构。

int seq=0;
do {
		seq = seqlock.read_entry();
		...... // 进行读操作
} while (seqlock.read_validate(seq));

如此可见,读操作的流程是如果读失败就回退重试,是个乐观读,有点类似于 CAS 算法。同 CAS 算法相比,顺序锁关注的是读操作,总是假设读操作会成功,在读完之后再判断在读过程中是否同时有写操作发生,如果有,则读到的是脏数据,就回退重试;而 CAS 算法更常见的是写操作,总是假设写操作会成功,就尝试写,在写的时候,如果已经有别的写操作发生了,就回退重试,属于乐观写。乐观读是读完之后,检查数据是否更新过,乐观写是在本地修改完,在保存之前检查原值是否更新过。

不过,while 循环内需要做的是把数据保存到临时对象副本中,然后再验证数据是否有效,这样当发现读到的数据是脏数据后,直接丢弃这个临时对象就行了。不要直接使用数据作任何业务处理,首先,有可能处理业务耗时太长,写者又进行了修改,导致重试,前面的处理就白费了;其次,如果发现数据是脏数据,处理结果也就无效,对处理结果就得进行回滚操作,如果是非幂等操作的话,可能就无法回滚了,为错误恢复操作带来了复杂的逻辑。

如果需要使用这些数据进行更进一步的处理,可以使用存放在临时对象的副本数据在 while 循环外面执行,那时已经经过验证,是准确无误,状态一致的数据。

3、写线程的代码样板:
写写之间肯定是互斥的,因此在写操作时,需要申请写锁,写完之后释放锁。在编程实践中,可以为顺序锁按照RAII惯例提供lock_guard相关操作,实现自动释放锁的功能。

seqlock.write_lock();
...... // 进行写操作
seqlock.write_unlock();

特点

1、读操作不持有锁,因此读不影响写,在读操作期间并不阻止写操作同时进行。读读不互斥,而且读写之间也不互斥,只有写写之间互斥。

2、虽然使用了 CAS 算法,但不存在 ABA 问题,因为写者获取到锁时,顺序号加 1,释放锁时也是加 1,不会出现加 1 后又减 1 的场景,不可能导致顺序号相同。虽然计数器可以溢出后又回到了 0,重新开始循环,但是这是一个很长的过程,在进行一次读操作期间,如果计数器溢出,不可能在read_validate时遇到了计数器又环绕回read_entry()时的值,读操作的耗时得足够长,即它的一次读操作的耗时要经过2^31次(即20亿次)写操作的时间,才有可能。

3、因为读写之间没有锁,所以不像传统的读写锁那样,读操作有加锁、解锁的接口,而是一个获取一个序号,读完之后再验证这个序号 。当然,写写之间还是有锁的,要保证写写之间的安全。

4、用于写操作优先级高,数据类型简单的场所,尤其是实时更新的场所。比如时钟的定时更新,时钟驱动程序始终严格地按照时间间隔定期地写入数据,不可能因为得不到锁而延迟一段时间再写入,那样时间就不准了,参见linux内核中的顺序锁使用场景。

示例

下面看一个例子:
假设有一个100Mb带宽的网卡,在一个32位的CPU处理器上收发以太网报文,现在要统计自开机以来网卡接收的报文个数。由于用来统计的计数器的位数是32位,把它看作是无符号的整数,最大为2^32-1,按照百兆带宽接收报文的个数,在6天后这个32位的计数器就会溢出,显然使用32位的计数器是不行的,但是因为是32位系统,无法提供更高精度的计数器,比如64位的计数器,可以考虑使用两个32位计数器的组合来统计:一个用作低32位,另一个用作高32位,因此,对它们进行读写计算时就得要求必须是原子操作。网卡驱动程序作为写者在收到报文后,同时更新统计结果(及时的),读者在需要的时候可以进行查询(随机的)。

一般而言,既然是要求对两个变量进行原子操作,那么在操作时就得使用锁进行保护。如果使用互斥锁或者一般的普通读写锁,虽然能满足要求,但是读写之间是互斥的,写者要和读者竞争锁,在此场景是不合适的,因为对于网卡驱动程序来说,它可能会在读者持有锁的时候处于等待状态,而且等待的时间也不确定(因为读者是用户线程,当持有锁后,可能会发生被抢占,还可能进行耗时的操作)。显然,如果此时有网络报文到来了,驱动程序无法及时接收,最后导致网卡缓冲区写满,报文被丢弃,因此不允许出现写者等待的场景,顺序锁正好能满足这一特性,使用顺序锁是非常合适的方案。而对于读者来说,由于统计计数器是两个变量,访问它们时必须是一次完整的更新,不能出现计数器的低32位是这次的更新,而高32位是上一次的更新,顺序锁读操作也可以满足此要求。

下面的代码片段是模拟的例子:

// 初始化统计变量,它们是共享变量
static pkt_counter net_counter{0, 0};
void test() {
	static string counter64; // 使用字符串存放读者读到的数据,字符串不受数据位数的限制
	seq_lock rdlock;

	// 读者,随机的获取统计信息 
	for (int i=0; i<10; i++) {
		thread([&rdlock, i]() {
			srand(time(nullptr)+i);
			pkt_counter counter;
			int seq;
			while (true) {
				do {
					seq = rdlock.read_entry();
					counter.low = net_counter.low;
					counter.high = net_counter.high; 
				} while (rdlock.read_validate(seq));

//				... // 把两个高低32位的数合成64位的数据,放在counter64字符串中

				this_thread::sleep_for(chrono::milliseconds(rand()%2000+i*500));
			}
		}).detach();
	}

	// 模拟网卡驱动,写者定时的更新计数器,一直在后台运行 
	thread writer([&rdlock]() {
		while (true) {
			{
				rdlock.write_lock();
				uint16_t old = net_counter.low;
				net_counter.low += rand()%10000;
				if (old > net_counter.low) { // 低32位溢出,高32位加1 
					net_counter.high++;
				}
				rdlock.write_unlock();
			}

			this_thread::sleep_for(chrono::milliseconds(1000));
		}
	});

	writer.join();
}

在本例中,假设读者读到的数据要使用字符串表示,即把两个高低32位的数字合成一个用字符串表示的64位的数字,存放在字符串中:counter64,在读取数据时,它使用了一个临时对象 counter 来接收数据,开销很低,只是两个整型赋值操作,在 while 循环结束后进行合并转换处理。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值