多线程主题本身是个大课题,简单起见,将c++多线程编程的挑战归结于对资源的同步访问。这里的资源可以指单个变量,一个类,或者说一个线程产生的东西另外一个线程去消费。对此,c++缺乏语言层面的支持。下面介绍下常见的同步访问类型。
1. 整型值的同步访问,经典的例子就是递增或者递减变量,比如++i 等于是i=i+1,对i递增有如下几个步骤:从内存中取值,将该值加1,最后将新值保存到i的内存位置,这个过程成为"读-改-写"(RMW,Read-Modify-Write), 很显然该过程非原子的,是可以被其他线程中断的。各操作系统提供了线程安全的操作方式,这种做法可以节约可观的性能开销。
以下为一个简单的原子类型包装,原子类型常用于多线程引用计数。
class AtomicNumber
{
public:
AtomicNumber(){mValue=0;}
long Get(){return mValue;}
long Set(int n){return __sync_lock_test_and_set(&mValue,n);}
long Inc(){return __sync_add_and_fetch(&mValue,1);}
long Dec(){return __sync_sub_and_fetch(&mValue,1);}
long Add(int n){return __sync_add_and_fetch(&mValue,n);}
long Sub(int n){return __sync_sub_and_fetch(&mValue,n);}
bool Cas(long cmp,long newv) {return __sync_bool_compare_and_swap(&mValue,cmp,newv);}
private:
volatile long mValue;
};
2. 对代码块的同步访问:临界区,对大多数同步需求来说,单个原子操作不够,他们往往需要对某个代码块独占的访问,这也是我们最广泛使用的同步方式,也是相对来说代价高昂的一种方式,但是这种方式使用简单,不容易出错。这种方式往往会伴随内核切换,这种切换代价高昂。下面介绍一种特殊的基于轮询的一种互斥体,简单来说轮询就是不断反复测试来等待某个条件的改变,称之为自旋互斥体,轮询意味着会吞噬cpu周期。但是某些场合还是非常适合使用这种自旋互斥体的,比如临界区非常短小,资源竞争也不那么激烈的时候,这种方式就很适合。下面给出一个来自于facebook开源的超轻量的自旋锁,该实现的性能调优点就是先轮询等待最多4000次,超过4000次则主动交出cpu,防止竞争激烈的时候大量占用cpu。
class Sleeper { static const unsigned kMaxActiveSpin = 4000; unsigned spinCount; public: Sleeper() : spinCount(0) {} void wait() { if (spinCount < kMaxActiveSpin) { ++spinCount; asm volatile("pause"); } else { struct timespec ts = { 0, 500000 }; nanosleep(&ts, NULL); } } }; class SpinLock { public: SpinLock(){lock_.Set(FREE);} void Lock() { Sleeper sleeper; do { sleeper.wait(); } while(!lock_.Cas(FREE,LOCKED)); } void Unlock() { lock_.Set(FREE); } private: enum { FREE = 0, LOCKED = 1 }; AtomicNumber lock_; };
3:在线程间传递消息真心烦人,一步做错,数据就会损坏,有两种方式处理这个问题,一种就是简单的方式,一种是麻烦的方式。
简单的方式:使用你使用平台提供的锁(互斥、临界区域,或等效)。这从概念上不难理解,使用上更简单。你无需担心排列问题,库或OS会替你解决。使用锁的唯一问题就是慢(这里的“慢”是相对而言的:一般应用,它的速度足够了)。
困难的方式:使用无锁的方式,为什么不是每个人都使用无锁编程呢?嗯,它不那么容易用对。任何情况下,不管每个线程在做什么,你必须很小心,绝不能破坏数据。但更难的是顺序相关的保证。在多核环境,代码的执行顺序可能并不是我们表面看到编码顺序,因为编译器和cpu会进行各种优化。
考虑这个例子(a和b从0开始):
Thread A Thread B b = 42 a = 1 print(a) print(b)线程B可能会输出的一组值是什么呢?有可能是{0 0 },{1 42},{0 42}和{1 0}, {0 0}, {1 42}中的任何一组,这里有两种情况会导致乱序,一是编译器优化乱序,二是cpu执行乱序,为了保障这种顺序一致性就必须了解有什么手段可以保障,答案就是内存屏障
内存屏障:也就是让一个CPU处理单元中的内存状态对其它处理单元可见的一项技术,在不同的CPU架构上内存屏障的实现非常不一样,大多数的内存屏障都是复杂的话题,我们可以简单理解为一种保障代码执行顺序的手段。
分享两个实现,一个是单一生产者单一消费者队列,一个是多生产者多消费者队列lock-free 的消息队列,这是网络程序中经常用到的方式,实现这些队列的核心就是 atomic, 其中需要注意的就是内存访问的顺序控制,解决伪共享(false sharing)以及无锁算法。
4:linux 2.6.22以后的内核版本中增加了eventfd,可以用来实现线程间的等待通知机制,eventfd使用的仍然是“一切皆文件”的思想,创建好的eventfd可以通过epoll来监听读写事件,结合消息队列,可以实现高效的线程间通讯。比如线程A准备好了数据,作为生产者push进队列,通过eventfd通知线程B,线程B监听到读事件后,会被唤醒,线程B作为消费者从消息队里中pop消息
5:根据需求选择合适的同步或者通讯方式