C++性能优化:可伸缩性

可伸缩性

提高C++代码性能的几种方法主要有缩短代码执行路径、添加处理器、升级处理器的速度。但是往往添加处理器也很难就是达到好的效果,有很多原因会导致添加的处理器数量和速度提升的倍数不成线性关系,比如单个处理器变为双处理器,但是执行速度就不一定会快2倍。
单处理器的体系结构中,主要是包含处理器、主存以及快速缓存。但多线程执行时主要通过抢占式的线程调度方式,看起来是并发,实际是cpu在轮流执行这几个线程。

SMP体系结构

SMP体系结构中有多个同样的cpu,多个线程可以同时执行。线程在没有特殊写明的情况下不会绑定在某个cpu上,也就是说一个cpu在上个时间片在执行这个线程任务,在下个时间片可能就会执行别的任务。
在多核cpu的场景中,比较大的瓶颈是缓存总线。多个cpu使用一个缓存总线就需要相互竞争,这样就会产生摩擦中断。解决的方法是每个cpu都给一个缓存,但这样的话又会存在缓存一致性问题。因此最终的方案是解决缓存一致性的问题的。

Amdahl法则

这个法则主要讲的是把程序潜在的可伸缩性进行量化。比如说进行矩阵运算的时候,一共使用10ms。其中,初始化和表示结果都消耗2ms,计算过程消耗6ms。我们都知道,在对程序进行优化时,一般都是对其程序的计算过程进行优化,比如此时加上了多核处理,这样的话可以优化计算时间。但其实最多也只能优化到4ms,因为初始化和表示的过程是难进行优化的。因此我们在进行优化的时候也需要考虑到比较难优化的部分进行性能优化,以下的几章会讲到这些。

多线程和同步术语

之前关于c++性能优化的文章中并没有提及过同步的术语。下面我们就简单的聊一下。由于多线程会有一些线程安全的问题,因此在开发过程中需要注意代码执行的原子性。比如两个线程执行x=x+1这条语句时,如果不加保护,就会导致发生错误,可能线程A执行行结束之后,将x变为x+1,这时B线程才执行,这样的话B线程得到的就是错误的。保证这样原子性的方式就是加锁,保证代码执行的串行化。
下面就会讲到一个任务拆成多个任务。使用多任务处理的方式主要是能释放程序的可伸缩性。多个任务比较典型的例子是web服务器,单个线程在接收的时候任务必须排队,一个任务在执行时,其他任务只能等待。要使其具有伸缩性,必须对任务进行拆解,将执行单个任务启用多线程,这样的话就可以提高程序的并行性和伸缩性。
提高程序每个单独响应请求的时间,服务器每秒处理的请求个数,对服务器的cpu更加充分地利用。

缓存共享

缓存共享数据使得线程访问数据的时候不需要上锁和解锁就可以重用它。

无共享

减少共享是比较好的做法,但是彻底消除共享那就是完全避开了问题。因此为每个私有进程分配资源就可以做到这点。
在之前的例子中,每个web服务器的工作者线程都维护一个指向其ThreadSpecificData私有数据对象的指针:

class ThreadSpecificData {
    HTRequest* reqPtr;};

reqPtr是一个指向HTRequest对象的指针,HTRequest包含HTTP的请求,每次启动工作者线程都会创建一个这样的对象,在每次结束都会进行清除。但是这样的话可能会出现问题。对象的创建和销毁是没有线程安全的,必须适应互斥锁进行保护才可以。
面对这样的问题解决办法就是把HTRequest放到ThreadSpecificData这个类中,而不是在堆上创建,这样的话在调用的时候就直接回去对象就可以进行init和reset。

class ThreadSpecificData {
    HTRequest reqPtr;};

这样的话,就相当于这个对象包含在外面的ThreadSpecificData里面,属于私有对象,不会存在共享问题。

部分共享

之前说的两个问题比较极端,一种是多线程池所有的线程开放资源,一种是无共享,所有的都不共享。那有没有比较折中的方案呢?这就得提到这个部分共享。我们把单个资源线程池改成多个线程池,这样可以减少资源竞争。但是,每个子池也应该加锁保护。这种子池的个数还是需要你的经验,不是越多越好,近乎极端的想法是一个池一个线程,那就是私有资源的概念,我们还是希望根据经验来找出最合适的方式。

锁的粒度

我们在使用互斥锁的时候都会遇到怎样加锁的问题。锁的粒度就是你加锁的时候,对于共享资源的控制。当你将两个共享资源都加上锁之后,那么这两个共享资源就会一起被加解锁。这种情况会是代码的执行效率降低。因此将一个锁换为为多个锁能有效的降低锁的粒度,资源的分配也更加的灵活。

伪共享

缓存的原子单元是行。一般缓存行的大小为128字节。有一种比较迷惑的情况是伪共享。比如说一个类中的两个锁,再定义的时候两个锁定义的区间相差8字节。这样就比较可能出现这两个锁在同一个缓存行的情况。如果一个锁的资源需要读取,这时操作系统读的是一整个缓存行,也就是说另一个锁的资源也会被读取。这样的话,当另一个锁读取的时候,就可能回报缓存失败。这就是伪共享的问题。

Thuudering Herd

我们常见的锁大致分为两种,一种是简单锁,每次获取锁的线程可以执行,其他线程进入睡眠;一种是旋转锁,一个线程获取锁,其他线程会一直循环获取,这样比较消耗cpu。
简单锁获取时可能会存在Thuudering Herd事件。大致意思就是当一个线程执行完之后释放锁的时候,睡眠的线程都会被唤醒,这会导致所有线程都会进行上下文切换,而只有一个线程会执行,其他线程的上下文切换工作就被浪费掉。这时候竞争带来的资源消耗不可忽视。

读/写锁

读写锁在一定程度上控制了线程的访问,它只允许单个线程写,但是读取锁住资源就可以并发使用。但是这种读写锁也会在使用上有极端性。如果所有的线程都需要写操作,那就相当于复杂一些的简单锁,比简单锁更加消耗性能;但如果都是读锁的话那就可以在一定程度上提高程序的伸缩性。

总结

SMP是当前比较主流的处理器体系结构,多核处理器通过一个总线,一个缓存进行连接。总线是比较薄弱的环节,每个处理器包含一个大型缓存说明总线的竞争受到控制。
Amdahl法则给出了潜在可伸缩性的上限。被串行化的计算部分造成了可伸缩性的上限。
实现可伸缩性的技巧:
任务分解,但一执行任务分为多个并发进行;
代码移出,临界区只能放共享资源有关的代码,其他代码应该移出;
利用缓存,通过缓存以前的结果,可以消除对临界区的访问。
如果需要少量固定的资源,应该避免使用共享资源,应该将共享变为私有。
部分共享:使用两个同样的池,竞争减少一半总是好的
锁粒度:除非资源同时更新,否则不要把资源放在同一个锁的保护下
伪共享:不要在类定义的时候把两个热门锁放在非常接近的地方,这样的话可以避免缓存一致性风暴
Thuurding Herd:多个线程被同时唤醒时会触发有上下文切换,造成cpu性能损失
系统和库调用:内部其实隐藏着许多串行化代码
读/写锁:以读为主的共享数据得益于这种锁,这类锁消除了读写线程之间的竞争。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值