2. Lock -free 应用场景二 —— Seqlock
手表最主要最常用的功能是读时间,而不是校正时间,一旦后者成了最常用的功能,消费者肯定不会买账。计算机的时钟也是这个功能,修改时间是小概率事件,而读时间是经常发生的行为。以下代码摘自 2.4.34 内核:
清单 5. 2.4.34 seqlock 实现代码
443 void do_gettimeofday(struct timeval *tv) 444 { …… 448 read_lock_irqsave(&xtime_lock, flags); …… 455 sec = xtime.tv_sec; 456 usec += xtime.tv_usec; 457 read_unlock_irqrestore(&xtime_lock, flags); …… 466 } 468 void do_settimeofday(struct timeval *tv) 469 { 470 write_lock_irq(&xtime_lock); …… 490 write_unlock_irq(&xtime_lock); 491 }
不难发现获取时间和修改时间采用的是 spin lock 读写锁,读锁和写锁具有相同的优先级,只要读持有锁,写锁就必须等待,反之亦然。
Linux 2.6 内核中引入一种新型锁——顺序锁 (seqlock),它与 spin lock 读写锁非常相似,只是它为写者赋予了较高的优先级。也就是说,即使读者正在读的时候也允许写者继续运行。当存在多个读者和少数写者共享一把锁时,seqlock 便有了用武之地,因为 seqlock 对写者更有利,只要没有其他写者,写锁总能获取成功。根据 lock-free 和时钟功能的思想,内核开发者在 2.6 内核中,将上述读写锁修改成了顺序锁 seqlock,代码如下:
清单 6. 2.6.10 seqlock 实现代码
static inline unsigned read_seqbegin(const seqlock_t *sl) { unsigned ret = sl->sequence; smp_rmb(); return ret; } static inline int read_seqretry(const seqlock_t *sl, unsigned iv) { smp_rmb(); return (iv & 1) | (sl->sequence ^ iv); } static inline void write_seqlock(seqlock_t *sl) { spin_lock(&sl->lock); ++sl->sequence; smp_wmb(); } void do_gettimeofday(struct timeval *tv) { unsigned long seq; unsigned long usec, sec; unsigned long max_ntp_tick; …… do { unsigned long lost; seq = read_seqbegin(&xtime_lock); …… sec = xtime.tv_sec; usec += (xtime.tv_nsec / 1000); } while (read_seqretry(&xtime_lock, seq)); …… tv->tv_sec = sec; tv->tv_usec = usec; } int do_settimeofday(struct timespec *tv) { …… write_seqlock_irq(&xtime_lock); …… write_sequnlock_irq(&xtime_lock); clock_was_set(); return 0; }
Seqlock 实现原理是依赖一个序列计数器,当写者写入数据时,会得到一把锁,并且将序列值加 1。当读者读取数据之前和之后,该序列号都会被读取,如果读取的序列号值都相同,则表明写没有发生。反之,表明发生过写事件,则放弃已进行的操作,重新循环一次,直至成功。不难看出,do_gettimeofday 函数里面的 while 循环和接下来的两行赋值操作就是 CAS 操作。
采用顺序锁 seqlock 好处就是写者永远不会等待,缺点就是有些时候读者不得不反复多次读相同的数据直到它获得有效的副本。当要保护的临界区很小,很简单,频繁读取而写入很少发生(WRRM--- Write Rarely Read Mostly)且必须快速时,就可以使用 seqlock。但 seqlock 不能保护包含有指针的数据结构,因为当写者修改数据结构时,读者可能会访问一个无效的指针。