性能优化策略之锁竞争优化
笔者曾在周伟明老师的《多核计算与程序设计》中看到以下这么一段代码:
int _tmain(int argc, _TCHAR* argv[]) { CRITICAL_SECTION cs; int i = 0; clock_t t1,t2; InitializeCriticalSection(&cs); t1 = clock(); for ( i = 0; i < 10000000; i++ ) { EnterCriticalSection(&cs); LeaveCriticalSection(&cs); } t2 = clock(); printf("One Task, CriticalSection 10,000,000times, time = %ldms\n", t2-t1); t1 = clock(); #pragma omp parallel for num_threads(2) //将下面for循环内代码分成两个线程来运行 for ( i = 0; i < 10000000; i++ ) { EnterCriticalSection(&cs); LeaveCriticalSection(&cs); } t2 = clock(); printf("Two Task, CriticalSection 10,000,000times, time = %ldms\n", t2-t1); DeleteCriticalSection(&cs); return 0; }
虽说现在I5甚至I7的处理器已经大量流行,以上程序的测试结果2比结果1稍微少了几毫秒。但是笔者在一台赛扬双核1.8的电脑上测试正如周伟明老师所说结果2比结果1耗时一倍还不止,由此可见锁竞争的影响。本文笔者就为大家介绍一下常见的锁竞争优化策略。
1、读写锁。读写锁顾名思义就是把读和写分开的一种锁机制,这样可以保证多个线程同时对一个队列进行读操作,然而有写操作时其他读操作和写操作必须等待。这样以来保证在多读少写的操作中例如数据库具备极高的效率,正因为如此微软从Vista系统开始将这种锁机制绑定到系统SDK,但考虑到window XP的极高装机量所以读写要在window系统上使用恐怕还是得自己实现为妙。
2、多队列缓冲技术。在实际项目中经常遇到类似一个线程读数据到队列另一个线程从队列中取数据的情况。但是如果两个线程操作太过频繁则会造成大量进锁出锁操作因为引起锁竞争,所以为了减少这种大量出锁进锁可以弄两个队列,一个队列为读线程读数据的队列,另一个队列为处理数据的线程用的队列,当前一个队列数据达到一定时一并复制到另外一个队列,将N次进锁出锁变成一次因为减少锁竞争。同时也可以将这引申出更多队列的缓冲技术。
3、无锁队列。无锁队列本质上是用的原子操作属性,例如window上的InterlockedCompareExchange API。其本质也是把内存锁住了。类似这种操作其实就是将锁的粒度变得更小而锁内部的操作尽可能的少,通过这样也可以减少一部分锁竞争。
4、双锁并发队列。顾名思义就是两个锁的队列,因为一个队列也就只有进队列和出队列两个操作,如果其中一个操作进行时要把整个队列锁住也未免太过浪费了,所以很早前有人设计出一种队列,使用插入锁和删除锁两个操作。但是如果当队列只有一个元素时未免会出现问题,所以设计的人最初就保证队列中至少有一个元素以保证隔开插入和删除操作。据说有人使用这种队列将一个win32程序效率提高了百分之十一。按照网上的算法笔者写出了window版本的代码,曾经在过去多次使用,读者也可直接拷贝使用。但是从效率着想建议配合内存池使用。
#pragma once #include <Windows.h> template<class T> struct NODE { T value; NODE *next; }; template<class T> class CDoublelockQueue { public: CDoublelockQueue(void) { //始终保证队列内至少有一个元素,避免死锁 NODE<T> *node = new NODE<T>(); node->next = NULL; m_headNode = m_tailNode = node; InitializeCriticalSection(&m_csTaillock); InitializeCriticalSection(&m_csHeadlock); } ~CDoublelockQueue(void) { DeleteCriticalSection(&m_csTaillock); DeleteCriticalSection(&m_csHeadlock); delete m_headNode; } //插入的时候只在尾部操作,使用尾部锁 void push(T value) { NODE<T> *node = new NODE<T>(); node->value = value; node->next = NULL; EnterCriticalSection(&m_csTaillock); m_tailNode->next = node; m_tailNode = node; LeaveCriticalSection(&m_csTaillock); } //弹出的时候在头部操作,使用头部锁 BOOL pop(T &value) { EnterCriticalSection(&m_csHeadlock); NODE<T> *node = m_headNode; NODE<T> *new_head = node->next; if (!new_head) { LeaveCriticalSection(&m_csHeadlock); return FALSE; } value = new_head->value; m_headNode = new_head; LeaveCriticalSection(&m_csHeadlock); delete node; return TRUE; } private: NODE<T> *m_headNode; NODE<T> *m_tailNode; CRITICAL_SECTION m_csTaillock; CRITICAL_SECTION m_csHeadlock; };
有人曾经问我如果队列中只有一个元素的时候同时读写是不是会出问题,这说明问这个问题的人想的很周到。但事实上对一个16,32字节的内存同时做读写操作现在的CPU已经可以保证原子性操作了,所谓原子性就是不可再分,也就是说如果现在的CPU绝对不会在读到一半的时候做写操作,反过来也是一样。所以说这种情况在现今的处理器上是不会有问题的。当然这种原子性仅仅只是对类似32字节的内存直接读或者写操作,但是类似i++这种会被编译成3条指令的代码则必须采用锁保护。