无锁编程与有锁编程的性能对比与分析

最近维护的一个网络服务器遇到性能问题,于是就对原有的程序进行了较大的框架改动。改动最多的是线程工作模式与数据传递方式,最终的结果是改变锁的使用模式。经过一番改进,基本上可以做到 GMb 网卡全速工作处理。在 性能达标之后,一度在想有没有什么办法使用更加轻量级锁,或者去掉锁的使用,为此搜索一些相关的研究成果,并做了一些实验来验证这些成果,因而就有这篇文章。希望有做类似工作的同行可以有所借鉴。如果有人也有相关的经验,欢迎和我交流。

1 无锁编程概述

本节主要对文献 [1] 进行概括,做一些基础知识的介绍。

所谓有锁编程,就是当你需要共享数据的时候,你需要有序的去访问,所有改变共享数据的操作都必须表现出原子的语义,即便是像 ++k ,这种操作也需要使用锁进行。有锁编程面临时效率的下降、死锁、优先级反转等问题,都需要设计者小心的进行优化和解决。本文并不对这三个问题进行讨论。

在无锁编程中,并不是说所有操作都是原子的,只有一个很有限的操作集是原子的,这就意味着无锁编程十分困难。那么这个有限的操作集是否存在,存在的话包含哪些原子操作呢? 2003 年 Maurice Herlihy 的一篇论文 ”Wait-Free Synchronization”[3] 解决了这个问题。这里给出文章的结论,文章指出像 test-and-set,swap,fetch-and-add 甚至是原子队列对于多线程而言都无法做到 lock-free 。而最朴素最简单的原语 CAS(compare-and-swap) 操作即可以完成所有的无锁功能,其他的如 LL/SC
(load linked/store conditional) 。 CAS 的伪码如下:

template <class T>
bool CAS(T* addr, T expected, T value)
{
   if (*addr == expected)
   {
      *addr = value;
      return true;
   }
   return false;
}

CAS 将 expected 与一个内存地址进行比较,如果比较成功,就将内存内容替换为 new 。当前大多数机器都在硬件级实现了这个操作,在 Inter 处理器上这个操作是 CMPXCHG ,因而 CAS 是一个最基础的原子操作。

wait-free / lock-free 与 有锁对比

wait-free 的过程可以通过有限步骤完成,而不管其他线程的速度。

lock-free 的过程保证至少一个线程在执行,其他线程可能会被延迟,但系统整体仍在前进。

有锁的情况下,如果某个线程占有锁,则其他线程就无法执行。更普通的,有锁需要避免死锁和活锁的情况。

2 无锁编程的相关研究与进展

本节内容对文献 [2] 进行概述,介绍当前已经实现的无锁算法与数据结构。

近二十年来研究者们对 lock-free 和 wait-free 的算法和数据结构进行了大量的研究。实现了一些 wait-free 和 lock-free 的算法,比如 FIFO 的队列和LIFO 的栈,而更复杂的优化级队列、 hash 表及红黑树的 lock-free 算法也渐渐为人所知。

无锁算法的实现都依赖内存屏障,因而具有平台相关性。下面将列举目前已经较为成熟的原子操作和算法数据结构的实现。

  • MidiShare Source Code is available under the GPL license. MidiShare includes implementations of lock-free FIFO queues and LIFO stacks.
  • Appcoreis an SMP and HyperThread friendly library which uses Lock-free techniques to implement stacks, queues, linked lists and other useful data
    structures. Appcore appears currently to be for x86 computers running Windows. The licensing terms of Appcore are extremely unclear.
  • Noble – a library of non-blocking synchronisation protocols. Implements lock-free stack, queue, singly linked list, snapshots and registers. Noble is
    distributed under a license which only permits non-commercial academic use.
  • lock-free-lib published under the GPL license. Includes implementations of software transactional memory, multi-workd CAS primitives,
    skip lists, binary search trees, and red-black trees. For Alpha, Mips, ia64, x86, PPC, and Sparc.
  • Nonblocking multiprocessor/multithread algorithms in C++ (for MSVC/x86) posted by Joshua Scholar to musicdsp.org ,
    and are presumably in the public domain. Included are queue, stack, reference-counted garbage collection, memory allocation, templates for atomic algorithms and types. This code is largely untested. A local mirror is here .
  • Qprof includes the Atomic_ops library of atomic operations and data structures under an MIT-style license. Only available for Linux at the
    moment, but there are plans to support other platforms. download available here
  • Amino Concurrent Building Blocks provides lock free datastructures and STM for C++ and Java under an Apache Software (2.0) licence.

其中 Noble 已经进行了商业化, License 相当不便宜。

3 性能分析

本节对 PTHREAD 中的 mutex , windows 中的原子增,及 CAS 原子操作进行对比,并对 MidiShare 中实现的无锁 FIFO 队列与基于 STL 的 list 实现的有锁队列进行的性能对比和分析,并对优化方式进行了总结。

3.1 原子增的性能测试

测试机 CPU 为 Intel E5300 2.60GHZ

首先是对简单的递增操作进行了测试,分别对无任何同步机制的 ++ 操作、 pthread_mutex 保护的 ++ 操作,以及 CAS 的语义实现的 atomic_add1()以及 windows 下的 interlockedIncrease() 进行了单个线程情况下的定量测试。


i++


3.2 亿


lock(p_mutex);i++;unlock(p_mutex);


2 千万


CAS_atomic_add1(i)


4 千万


interlockedIncrease(&i)


4 千万

首先在无任何同步情况下, CPU 可以每秒执行 ++ 操作 3.2 亿次,接近于 CPU 的主频速率。而每次 ++ 时执行 thread_mutex_lock() 及 unlock() 操作情况下, CPU 每秒只能执行 2 千万次,这就是说 CPU 每秒钟可以执行加锁及解锁操作共 4 千万次,加解锁的开销是执行加法指令的的 15 倍左右。而CAS 的情况稍好,为每秒 4 千万次。这个速度与 windows 下的 interlockedIncrease() 的执行速度十分近似。

从上面的测试结果来看, windows 下的原子增操作与 CAS 实现的增操作代价基本是相同的,估计 windows 底层也是借助汇编指令 CMPXCHG 的 CAS来实现原子增操作的。当然 pthread_mutex 作为一种互斥锁,也是拥有相当高的效率的,在没有锁突然的情况下,加锁开销与一次 CAS 的开销相当。

但如果对比无同步的 ++ 操作,硬件级的同步也造成了至少 8 倍的性能下降。

接着,对 pthread_mutex 的程序进行了逻辑优化,分别测试了 ++ 执行 8 次、 20,100 次进行一次加解锁的情况。


lock();for(k=0;k<8;i++,k++);unlock()


1.2 亿


lock();for(k=0;k<20;i++,k++);unlock()


2 亿


lock();for(k=0;k<100;i++,k++);unlock()


3.4 亿

结果 CPU 每秒钟可以执行 ++ 的次数为 1.2 亿 /2 亿 /3.4 亿,这种情况与预期是一致的,因为每秒钟调用加解锁的次数分别是原来的 1/8 、 1/20 和1/100 ,当执行 100 次 ++ 进行一次加解锁后,性能已经达到了无任何同步时的性能。当然原子的 interlockedIncrease() 和 CAS 实现的 atomic_add1() 都不具备这种批量处理的改进优势,无论如果,它们最好的执行情况已经固定了。

对于在单线程与多线程的情况下的 windows 下的原子操作的性能测试情况,可以参考文献 [4] ,这里只列出其中的结论。其所列的测试机 CPU 为Intel2.66GHZ 双核处理器。

单个线程执行 2 百万次原子增操作


interlockedIncrease


78ms


Windows CriticalSection


172ms


OpenMP 的 lock 操作


250ms

两个线程对共享变量执行 2 百万次原子增操作


interlockedIncrease


156ms


Windows CriticalSection


3156ms


OpenMP 的 lock 操作


1063ms

3.2 无锁队列与有锁队列的性能测试

这里测试的无锁列队由 MidiShare 实现的,而有锁队列是通过 pthread_mutex 与 c++ 的 STL list 共同实现。这里只列出测试结果。

对于存储相同的数据的情况下,从主线程 enque 并从子线程 deque ,计算每秒钟 enque/deque 的次数,当然二者基本上是相同的。

无锁队列的性能在 150w -200w 次入队操作,这个性能已经无法再有任何提高,因为每次入队出队操作都是硬件级的互斥。而对于有锁队列,根据每次加解锁之间处理入队的次数的不同,有以下的结果:


lock();for(k=0;k<x;i++,k++);unlock()


结果(次/s)


x=1


40 万


x=10


190 万


x=128


350 万


x=1000


400 万


x=10000


396 万

这说明通过对锁之间的数据进行批处理,可以极大的提高系统的性能,而使用原子操作,则无法实现批处理上的改进。

4 结论

通过上面的无锁和有锁的性能测试,可以得出这样的结论,对于 CAS 实现的硬件级的互斥,其单次操作性能比相同条件下的应用层的较为高效,但当多个线程并发时,硬件级的互斥引入的代价与应用层的锁争用同样令人惋惜。因此如果纯粹希望通过使用 CAS 无锁算法及相关数据结构而带来程序性能的大量提升是不可能的,硬件级原子操作使应用层操作变慢,而且无法再度优化。相反通过对有锁多线程程序的良好设计,可以使程序性能没有任何下降,可以实现高度的并发性。

但是我们也要看到应用层无锁的好处,比如不需要程序员再去考虑死锁、优先级反转等棘手的问题,因此在对应用程序不太复杂,而对性能要求稍高时,可以采用有锁多线程。而程序较为复杂,性能要求满足使用的情况下,可以使用应用级无锁算法。

至于如何对多线程的工作模式进行更好的调度,可以参考文献 [5] ,文献介绍了一种较好的线程间合作的工作模式,当然前提是机器的处理器个数较多,足以支持多组线程并行的工作。如果处理器个数较,较多的线程之间在各个核心上来回调度增加了系统上下文切换的开销,会导致系统整体性能下降。

参考文献

[1] Lock-Free Data Structures http://www.drdobbs.com/184401865

[2] Some notes on lock-free wait-free algorithms http://www.rossbencina.com/code/lockfree

[3] Wait-Free Synchronization http://www.podc.org/dijkstra/2003.html

[4] OpenMP 创建线程中的锁及原子操作性能比较 http://blog.163.com/kangtao-520/blog/static/772561452009510751068/

[5] 多核编程中的线程分组竞争模式 http://kangtao-520.blog.163.com/blog/static/77256145200951074121305/

无锁编程与有锁编程的性能对比与分析

时间: 2024-10-10 09:36:09

无锁编程与有锁编程的性能对比与分析的相关文章

Java并发编程:Concurrent锁机制解析

.title { text-align: center } .todo { font-family: monospace; color: red } .done { color: green } .tag { background-color: #eee; font-family: monospace; padding: 2px; font-size: 80%; font-weight: normal } .timestamp { color: #bebebe } .timestamp-kwd

多线程编程之自旋锁

一.什么是自旋锁 一直以为自旋锁也是用于多线程互斥的一种锁,原来不是! 自旋锁是专为防止多处理器并发(实现保护共享资源)而引入的一种锁机制.自旋锁与互斥锁比较类似,它们都是为了解决对某项资源的互斥使用.无论是互斥锁,还是自旋锁,在任何时刻,最多只能有一个保持者,也就说,在任何时刻最多只能有一个执行单元获得锁.但是两者在调度机制上略有不同.对于互斥锁,如果资源已经被占用,资源申请者只能进入睡眠状态.但是自旋锁不会引起调用者睡眠,如果自旋锁已经被别的执行单元保持,调用者就一直循环在那里看是否该自旋锁

UNIX网络编程:互斥锁和条件变量

在网络编程中,一般都是多线程的编程,这就出现了一个问题:数据的同步与共享.而互斥锁和条件变量就是为了允许在线程或进程间共享数据.同步的两种最基本的组成部分.它们总能够用来同步一个进程中的多个线程. 再进入互斥锁和条件变量之前,我们先对多线程的一些相关函数进行简单介绍: 多线程简单介绍和相关函数: 通常,一个进程中包括多个线程,每个线程都是CPU进行调度的基本单位,多线程可以说是在共享内存空间中并发地多道执行程序,与进程相比,线程的具有以下优点: ● 减少系统调度开销.由于线程基本不拥有资源,因此

并发编程--CAS自旋锁

在前两篇博客中我们介绍了并发编程--volatile应用与原理和并发编程--synchronized的实现原理(二),接下来我们介绍一下CAS自旋锁相关的知识. 一.自旋锁提出的背景 由于在多处理器系统环境中有些资源因为其有限性,有时需要互斥访问(mutual exclusion),这时会引入锁的机制,只有获取了锁的进程才能获取资源访问.即是每次只能有且只有一个进程能获取锁,才能进入自己的临界区,同一时间不能两个或两个以上进程进入临界区,当退出临界区时释放锁.设计互斥算法时总是会面临一种情况,即

高效编程之互斥锁和自旋锁的一些知识

两种锁的加锁原理 互斥锁:线程会从sleep(加锁)-->running(解锁),过程中有上下文的切换,cpu的抢占,信号的发送等开销. 自旋锁:线程一直是running(加锁-->解锁),死循环检测锁的标志位,机制不复杂. 两种锁的区别 互斥锁的起始原始开销要高于自旋锁,但是基本是一劳永逸,临界区持锁时间的大小并不会对互斥锁的开销造成影响,而自旋锁是死循环检测,加锁全程消耗cpu,起始开销虽然低于互斥锁,但是随着持锁时间,加锁的开销是线性增长. 两种锁的应用 互斥锁用于临界区持锁时间比较长的

多线程并发编程之显示锁ReentrantLock和读写锁

在Java5.0之前,只有synchronized(内置锁)和volatile. Java5.0后引入了显示锁ReentrantLock. ReentrantLock概况 ReentrantLock是可重入的锁,它不同于内置锁, 它在每次使用都需要显示的加锁和解锁, 而且提供了更高级的特性:公平锁, 定时锁, 有条件锁, 可轮询锁, 可中断锁. 可以有效避免死锁的活跃性问题.ReentrantLock实现了 Lock接口: public interface Lock { //阻塞直到获得锁或者中

多线程编程之顺序锁

一.什么是顺序锁 顺序锁对读写锁的一种优化,使用顺序锁时,读不会被写执行单元阻塞(在读写锁中,写操作必须要等所有读操作完成才能进行).也就是说,当向一个临界资源中写入的同时,也可以从此临界资源中读取,即实现同时读写,但是不允许同时写数据.如果读执行单元在读操作期间,写执行单元已经发生了写操作,那么,读执行单元必须重新开始,这样保证了数据的完整性,当然这种可能是微乎其微.顺序锁的性能是非常好的,同时他允许读写同时进行,大大的提高了并发性. 二.顺序锁的缺陷 顺序锁的缺陷在于,互斥访问的资源不能是指

Java多线程编程4--Lock的使用--公平锁和非公平锁

公平与非公平锁:锁Lock分为"公平锁"和"非公平锁",公平锁表示线程获取锁的顺序是按照线程加锁的顺序来分配的,即先来先得的FIFO先进先出顺序.而非公平锁就是一种获取锁的抢占机制,是随机获得锁的,和公平锁不一样的就是先来的不一定先得到锁,这个方式可能造成某些线程一直拿不到锁,结果也就是不公平的了. 1.公平锁实例 public class Service { private ReentrantLock lock ; public Service(boolean i

并发编程---死锁||递归锁---信号量---Event事件---定时器

死锁 互斥锁:Lock(),互斥锁只能acquire一次 递归锁:  RLock(),可以连续acquire多次,每acquire一次计数器+1,只有计数为0时,才能被抢到acquire # 死锁 from threading import Thread,Lock import time mutexA = Lock() mutexB = Lock() class MyThread(Thread): def run(self): self.f1() self.f2() def f1(self):