首页 > 代码库 > 一个无锁消息队列引发的血案:怎样做一个真正的程序员?(二)——月:自旋锁

一个无锁消息队列引发的血案:怎样做一个真正的程序员?(二)——月:自旋锁

前续

一个无锁消息队列引发的血案:怎样做一个真正的程序员?(一)——地:起因

一个无锁消息队列引发的血案:怎样做一个真正的程序员?(二)——月:自旋锁

平行时空

  在复制好上面那一行我就先停下来了,算是先占了个位置,虽然我知道大概要怎么写,不过感觉还是很乱。

  我突然想到,既然那么纠结,那么混乱,那么不知所措,我们不如换个视角。记得高中时看过的为数不多的长篇小说《穆斯林的葬礼》,作者是:霍达(女),故事描写了两个发生在不同时代、有着不同的内容却又交错扭结的爱情悲剧,一个是“玉”的故事,一个是“月”的故事。结构上采取交叉的模式,一章写“玉”,一章写“月”,分别写两代人的命运,手法新颖,别有一番风味,用电影语言来讲,就是平行蒙太奇。用在这里再好不过了,就这么决定了!

技术分享

  之所以会想到这个,源自于昨天下午还没开始写第一篇后半部分之前,我本着对读者负责的态度,特意搜索了一下“自旋锁”这个关键词,虽然说不陌生,但想到从来没有通过搜索引擎认真研究过这个词,还是有些必要。搜索的结果,有用的信息的确并不多(不是说完全没用,是基本没用),但有一条还是不错的,这也给我解释自旋锁提供了良好的思路和理论依据。

  这篇文章就是《自旋锁代替互斥锁的实践》,这是一篇译文,原版的文章在这里:Practice of using spinlock instead of mutex ,说句题外话,昨天我研究 disruptor ,搜索的一些重要的文章 也是在 并发编程网(ifeve.com) ,而且 disruptor 更新了,文章也会跟着更新,虽然跟不上 disruptor 的更新速度:(

  这里解释一下,我把两个时空定义为:“地球”和“月球”,地球:喧闹而嘈杂,到处充满着竞争,这条线主要写事情的经过和free-lock(无锁编程),我偶尔也会喷一下人;月球:代表着宁静和理性,杳无人迹,也远离那些尘世的纷争,这条线主要描述自旋锁(混合自旋锁,hybrid spinlocks),同时,月亮也是一种美的象征(我本来想选火星,可是火星太不美了……而且“火星人”别有他意,你懂的)。

  好啦,我们开始吧,菠萝菠萝蜜!

时空穿梭

  你好,欢迎来到月球。

  下面我们来谈一谈自旋锁。为什么要谈自旋锁,我们之前不是在讲无锁消息队列吗?对的,可是分析了 q3.h 后,发现其实其大致相当于两个自旋锁在运作。

  至于为什么相当于两个自旋锁,我们后面再讲,先来研究一下 互斥锁(mutex locks) 和 自旋锁(spin locks)。

互斥锁与自旋锁

  互斥锁自旋锁多线程编程 中的重要概念。我们用它们来锁住一些共享资源,以防止并发访问这些共享数据时可能导致的数据不一致性问题。但是它们的不同之处在哪里? 我们应该在什么时候用 自旋锁 来代替 互斥锁 呢?

理论分析

  我们在使用 互斥锁 的时候,假设 互斥锁 已经被某个线程持有(锁住了),那么另一个线程在尝试加锁的时候会失败,然后进入休眠状态以等待其他线程的运行。这个线程会一直休眠到持有锁的线程释放了锁以后,才会被唤醒。那么我们来看看 自旋锁,如果一个线程在尝试持有一个 自旋锁 的时候,如果持有没有成功(即有其他线程已经先持有该锁了,已锁住了),该线程会一直尝试持有改锁(通过在用户态自旋实现的),那么它不允许其他线程在CPU的当前核心上运行(当然,操作系统可以通过中断或线程的时间片用完后强制转换到别的线程上运行)。

存在的问题

  互斥锁 存在的问题是,线程的休眠和唤醒都是相当昂贵的操作,它们需要大量的CPU指令,因此需要花费一些时间。如果 互斥量 仅仅被锁住很短的一段时间, 用来使线程休眠和唤醒线程的时间会比该线程睡眠的时间还长, 甚至有可能比不断在 自旋锁 上轮训的时间还长。而 自旋锁 的问题是,如果 自旋锁 被持有的时间过长,其它尝试获取 自旋锁 的线程会一直轮训自旋锁的状态,这将非常浪费CPU的执行时间,很关键的一点是这些浪费的轮训时间都是 无用功 ,这时候该线程睡眠会是一个更好的选择。

解决方案

  在 单核 / 单CPU 的系统上使用 自旋锁 是没有意义的,因为它就一个运行线程/核心,你占着不放,那么其他线程将得不到运行,其他线程得不到运行,这个锁不能被解锁。换句话说,在 单核 / 单CPU 系统使用 自旋锁,除了浪费点时间外没有一点好处。这时如果让这个线程(记为线程A)休眠,其他线程就得以运行,然后就可能会解锁这个 自旋锁,线程A就可能在重新被唤醒后,如愿以偿的持有锁。

  在 多核 / 多CPU 的系统上,特别是大量的线程只会短时间的持有锁的时候,在使线程睡眠和唤醒上浪费大量的时间,也许会显著降低程序的运行性能。使用 自旋锁,线程可以充分利用系统调度程序分配的时间片(经常阻塞很短的时间,不用休眠,然后马上继续它们的工作了),以达到更高的处理能力和吞吐量。

实践

  程序员往往并不能事先知道哪种方案更好,比如,不知道运行环境CPU的核心数,不能很好的预估被锁区域的持续时间。操作系统也不能分辨哪些指令是特别针对单核或多核CPU做过优化的,所以大部分操作系统不严格区分 互斥锁自旋锁 。实际上,绝大多数现在操作系统采用的是 混合型互斥锁 (hybrid mutexes) 和 混合型自旋锁 (hybrid spinlocks)。 它们是什么意思呢?

混合型互斥锁

  混合型互斥锁,在多核系统上,它的表现起初跟 自旋锁 一样,如果一个线程不能获取(持有) 互斥锁,它不会马上进入休眠状态,因为互斥量可能很快就被解锁,所以这时表现得跟 自旋锁 一样。只有当尝试一段时间以后(或者一定次数,或其他指标),还不能持有改锁,它就会被切换到休眠状态。如果运行在 单核/单CPU 的系统上时,这种机制将不会自旋,也不该自旋(原因就像前面讲的,一点好处都没有)。

  最好的例子就是Windows上的临界区,有一个 API 叫 InitializeCriticalSectionAndSpinCount(mutex, dwSpinCount),该函数的 dwSpinCount 值,Windows 默认推荐是 4000 (相信很多熟悉Windows开发的人都知道),即自旋 4000 次,而这 4000 次是怎么自旋的,以何种指令的形式自旋我们不得而知,但是它地球是先尝试自旋 dwSpinCount 次后仍未能够持有 互斥量 才真正进入休眠状态。

混合型自旋锁

  混合型自旋锁,起初表现得跟正常的 自旋锁 一样,但是为了避免浪费大量的CPU时间做无用功,会有一个折中的策略。这个策略有可能不会切换到休眠状态或者尽量很晚才切换到休眠状态,先自旋一段时间,自旋多久,以何种形式自旋将是这种类型自旋锁的核心之一;此外,另一个更核心的内容是,你还可以决定是否放弃当前线程的执行(马上放弃或等一段时间再放弃,等的时间由自旋策略决定),把时间片让给别的线程,这样提高了别的线程解锁 自旋锁 的可能性(当然,线程切换导致上下文的切换甚至可能切换到别的CPU核心上从而导致缓存失效,这可能并不一定比线程进入休眠再唤醒的时间短,这个很难权衡,但是好处是其他线程得以运行,你进入休眠后让出时间片,本质上也会遇到同样的问题)。

  作者自己写的 RingQueue 里用的就是这种 混合型自旋锁,只是 混合型自旋锁 里讲究的是休眠的策略,策略不同,CPU的占用率,执行效率都有很大区别。例如,著名 Intel 的多线程并行库里的 spin_mutex(即spin_lock) 采用的是 spin_count *= 2,自旋次数依次增大两倍,而休眠策略则在 Windows 下是 SwitchToThread() (这个有很大的缺陷,以后会讲),Linux下是 sched_yiled() (Linux下使用这个合理很多,但是那个但……,也不足够好,从细节上来说,Windows 和 Linux 下都各有优劣,且都存在缺陷,后面会说到,不过这个函数的好处就是基本Linux只有这么一个接口,而不像Windows下细节太多且不全面而容易出现致命的缺陷)

  再比如 pthread 里的 pthread_spin_lock(),不过这是个不折不扣的 自旋锁,没有休眠策略。然后还有 boost 里提供的几个 spin_lock,boost/smart_ptr下面有一个,策略还是比较得当的,自旋策略正常,但是Windows下,使用了Sleep(0)和Sleep(1),而没使用SwitchToThread(),另外一个地方,好像 boost/log 下面的,休眠策略只使用了 SwithToThread(),而没有Sleep(0)和Sleep(1)。当然还有 folly 里的 SmallLocks.h,自旋策略OK,但休眠策略使用的类似Sleep(1)的形式。

  混合型自旋锁 的性能好坏直接由你将要锁住的资源的处理时间长短,以及你的 自旋锁 的具体休眠策略而决定,性能好坏可能很难界定,不过还是能给出一些相对硬性的指标,比如 总体运行时间 和 总的CPU占用率,虽然这两个指标是相互矛盾的,但是如果你的运行时间短,占用CPU的比率又低,那肯定就是更好的 自旋锁 啦,也就是我们常说的,既想牛儿吃的是草,又想牛儿挤出的是牛奶,效率上最低不能低于 互斥锁,这也是检验 混合型自旋锁 优劣的一个参考指标。

休眠

  好了,今天就写到这里,我也要开始休眠了。其实昨天晚上我研究了一晚上 disruptor,因此耽误了写这篇文章的时间。你在看上面文章内容的时候,肯定会在想,为什么要用 自旋锁互斥锁,我用 disruptor 不是更好吗?通过我的理解和研究,事实上并不是那样的,disruptor 好是好,但是是有局限的,它并没有规避多线程编程的真正问题,它只是把问题简化,尽量使用单线程,或最大程序的避免缓存、总线和资源间的争用,尽量都使用单一线程来处理问题。所以它是鼓励使用 单生产者 模式的,这样就有效的规避了一些多线程争用的问题,所以我用 disruptor 尝试了 多生成者,多消费者 的模式,试验的结果跟一般的自旋锁没有太大的区别,可能线程多了,还要更慢一些,所以 disruptor 的解决方案和我们的问题基本上可以讲是两个不同的命题,不是一个维度的东西,虽然看起来好像有那么一点雷同。但是两者都可以相互借鉴,disruptor 里面也定义了很多休眠策略,可是实际的效果并不理想。关于 disruptor,以后可能花一点篇幅来讲。

RingQueue

  RingQueue 的GitHub地址是:https://github.com/shines77/RingQueue,也可以下载UTF-8编码版:https://github.com/shines77/RingQueue-utf8。 我敢说是一个不错的混合自旋锁,你可以自己去下载回来看看,支持Makefile,支持CodeBlocks, 支持Visual Studio 2008, 2010, 2013等,还支持CMake,支持Windows, MinGW, cygwin, Linux, Mac OSX等等,当然可能不支持ARM,没测试环境。

  (未完待续……敬请期待……)

一个无锁消息队列引发的血案:怎样做一个真正的程序员?(二)——月:自旋锁