C++性能榨汁机之无锁编程

时间:2023-03-09 05:25:27
C++性能榨汁机之无锁编程

C++性能榨汁机之无锁编程

来源 http://irootlee.com/juicer_lock_free/

前言

私以为个人的技术水平应该是一个螺旋式上升的过程:先从书本去了解一个大概,然后在实践中加深对相关知识的理解,遇到问题后再次回到书本,然后继续实践……接触C++并发编程已经一年多,从慢慢啃《C++并发编程实战》这本书开始,不停在期货高频交易软件的开发实践中去理解、运用、优化多线程相关技术。多线程知识的学习也是先从最基本的线程建立、互斥锁、条件变量到更高级的线程安全数据结构、线程池等等技术,当然在项目中也用到了简单的无锁编程相关知识,今天把一些体会心得跟大家分享一下,如有错误,还望大家批评指正。

多线程并发读写

在编写多线程程序时,最重要的问题就是多线程间共享数据的保护。多个线程之间共享地址空间,所以多个线程共享进程中的全局变量和堆,都可以对全局变量和堆上的数据进行读写,但是如果两个线程同时修改同一个数据,可能造成某线程的修改丢失;如果一个线程写的同时,另一个线程去读该数据时可能会读到写了一半的数据。这些行为都是线程不安全的行为,会造成程序运行逻辑出现错误。举个最简单的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <iostream>
#include <thread> using namespace std; int i = 0;
mutex mut; void iplusplus() {
int c = 10000000; //循环次数
while (c--) {
i++;
}
}
int main()
{
thread thread1(iplusplus); //建立并运行线程1
thread thread2(iplusplus); //建立并运行线程2
thread1.join(); // 等待线程1运行完毕
thread2.join(); // 等待线程2运行完毕
cout << "i = " << i << endl;
return 0;
}

上面代码main函数中建立了两个线程thread1和thread2,两个线程都是运行iplusplus函数,该函数功能就是运行i++语句10000000次,按照常识,两个线程各对i自增10000000次,最后i的结果应该是20000000,但是运行后结果却是如下:

C++性能榨汁机之无锁编程

i并不等于20000000,这是在多线程读写情况下没有对线程间共享的变量i进行保护所导致的问题。

有锁编程

对于保护多线程共享数据,最常用也是最基本的方法就是使用C++11线程标准库提供的互斥锁mutex保护临界区,保证同一时间只能有一个线程可以获取锁,持有锁的线程可以对共享变量进行修改,修改完毕后释放锁,而不持有锁的线程阻塞等待直到获取到锁,然后才能对共享变量进行修改,这种方法几乎是并发编程中的标准做法。大体流程如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
#include <iostream>
#include <thread>
#include <mutex>
#include <atomic>
#include <chrono> using namespace std;
int i = 0;
mutex mut; //互斥锁 void iplusplus() {
int c = 10000000; //循环次数
while (c--) {
mut.lock(); //互斥锁加锁
i++;
mut.unlock(); //互斥锁解锁
}
}
int main()
{
chrono::steady_clock::time_point start_time = chrono::steady_clock::now();//开始时间
thread thread1(iplusplus);
thread thread2(iplusplus);
thread1.join(); // 等待线程1运行完毕
thread2.join(); // 等待线程2运行完毕
cout << "i = " << i << endl;
chrono::steady_clock::time_point stop_time = chrono::steady_clock::now();//结束时间
chrono::duration<double> time_span = chrono::duration_cast<chrono::microseconds>(stop_time - start_time);
std::cout << "共耗时:" << time_span.count() << " ms" << endl; // 耗时
system("pause");
return 0;
}

代码14行和16行分别为互斥锁加锁和解锁代码,29行我们打印程序运行耗时,代码运行结果如下:

C++性能榨汁机之无锁编程

可以看到,通过加互斥锁,i的运行结果是正确的,由此解决了多线程同时写一个数据产生的线程安全问题,代码总耗时3.37328ms。

无锁编程

原子操作是无锁编程的基石,原子操作是不可分隔的操作,一般通过CAS(Compare and Swap)操作实现,CAS操作需要输入两个数值,一个旧值(期望操作前的值)和一个新值,在操作期间先比较下旧值有没有发生变化,如果没有发生变化,才交换成新值,发生了变化则不交换。C++11的线程库为我们提供了一系列原子类型,同时提供了相对应的原子操作,我们通过使用这些原子类型即可摆脱每次对共享变量进行操作都进行的加锁解锁动作,节省了系统开销,同时避免了线程因阻塞而频繁的切换。原子类型的基本使用方法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#include <iostream>
#include <thread>
#include <mutex>
#include <atomic>
#include <chrono> using namespace std;
atomic<int> i = 0; void iplusplus() {
int c = 10000000; //循环次数
while (c--) {
i++;
}
}
int main()
{
chrono::steady_clock::time_point start_time = chrono::steady_clock::now();//开始时间
thread thread1(iplusplus);
thread thread2(iplusplus);
thread1.join(); // 等待线程1运行完毕
thread2.join(); // 等待线程2运行完毕
cout << "i = " << i << endl;
chrono::steady_clock::time_point stop_time = chrono::steady_clock::now();//结束时间
chrono::duration<double> time_span = chrono::duration_cast<chrono::microseconds>(stop_time - start_time);
std::cout << "共耗时:" << time_span.count() << " ms" << endl; // 耗时
system("pause");
return 0;
}

代码的第8行定义了一个原子类型(int)变量i,在第13行多线程修改i的时候即可免去加锁和解锁的步骤,同时又能保证变量i的线程安全性。代码运行结果如下:

C++性能榨汁机之无锁编程

可以看到i的值是符合预期的,代码运行总耗时1.12731ms,仅为有锁编程的耗时3.37328ms的1/3,由此可以看出无锁编程由于避免了加锁而相对于有锁编程提高了一定的性能。

总结

无锁编程最大的优势是什么?是性能提高吗?其实并不是,我们的测试代码中临界区非常短,只有一个语句,所以显得加锁解锁操作对程序性能影响很大,但在实际应用中,我们的临界区一般不会这么短,临界区越长,加锁和解锁操作的性能损耗越微小,无锁编程和有锁编程之间的性能差距也就越微小。

我认为无锁编程最大的优势在于两点:

  1. 避免了死锁的产生。由于无锁编程避免了使用锁,所以也就不会出现并发编程中最让人头疼的死锁问题,对于提高程序健壮性有很大积极意义
  2. 代码更加清晰与简洁。对于一个多线程共享的变量,保证其安全性我们只需在声明时将其声明为原子类型即可,在代码中使用的时候和使用一个普通变量一样,而不用每次使用都要在前面写个加锁操作,在后面写一个解锁操作。我写的C++期货高频交易软件中,有一个全局变量fund,存储的是当前资金量,程序采用线程池运行交易策略,交易策略中频繁使用到fund变量,如果采用加锁的方式,使用起来极其繁琐,为了保护一个fund变量需要非常频繁的加锁解锁,后来将fund变量改为原子类型,后面使用就不用再考虑加锁问题,整个程序阅读起来清晰很多。

如果是为了提高性能将程序大幅改写成无锁编程,一般来说结果可能会让我们失望,而且无锁编程里面需要注意的地方也非常多,比如ABA问题,内存顺序问题,正确实现无锁编程比实现有锁编程要困难很多,除非有必要(确定了性能瓶颈)才去考虑使用无锁编程,否则还是使用互斥锁更好,毕竟程序的高性能是建立在程序正确性的基础上,如果程序不正确,一切性能提升都是徒劳无功。

C++性能榨汁机之CPU亲和性

来源 http://irootlee.com/juicer_cpu_affinity/

前言

CPU领域中最广为人知的一条定律——摩尔定律:预计18个月会将芯片的性能提高一倍。过去几十年,各大公司致力于提高CPU晶体管密度和提高CPU工作频率,使得CPU的性能提升基本符合摩尔定律。但随着工艺不断发展,晶体管密度提升已经接近物理极限,CPU工作频率也由于功耗和发热的制约而无法继续提升。在基础物理领域没有大的突破的前提下,单核CPU的性能提升日益困难,于是,各大公司将目光投向了通过增加CPU核心数提高性能领域,双核、4核、8核、16核等一系列多核CPU相继问世。

怎样合理调度多个CPU核心运行应用程序从而充分利用多核CPU的优势成为热门的研究问题,本文介绍的CPU亲和性便是解决该问题的方法之一。

什么是CPU亲和性?

引用一下*的说法,CPU亲和性就是绑定某一进程(或线程)到特定的CPU(或CPU集合),从而使得该进程(或线程)只能运行在绑定的CPU(或CPU集合)上。CPU亲和性利用了这样一个事实:进程上一次运行后的残余信息会保留在CPU的状态中(也就是指CPU的缓存)。如果下一次仍然将该进程调度到同一个CPU上,就能避免缓存未命中等对CPU处理性能不利的情况,从而使得进程的运行更加高效。

CPU亲和性分为两种:软亲和性和硬亲和性。软亲和性主要由操作系统来实现,Linux操作系统的调度器会倾向于保持一个进程不会频繁的在多个CPU之间迁移,通常情况下调度器都会根据各个CPU的负载情况合理地调度运行中的进程,以减轻繁忙CPU的压力,提高所有进程的整体性能。除此以外,Linux系统还提供了硬亲和性功能,即用户可以通过调用系统API实现自定义进程运行在哪个CPU上,从而满足特定进程的特殊性能需求。

如何将CPU亲和性应用到程序中?

Linux系统中每个进程的task_struct结构中有一个cpus_allowed 位掩码,该掩码的位数与系统CPU核数相同(若CPU启用了超线程则为核数乘以2),通过修改该位掩码可以控制进程可运行在哪些特定CPU上。Linux系统为我们提供了CPU亲和性相关的调用函数和一些操作的宏定义,函数主要是下面两个:

  • sched_set_affinity() (修改位掩码)

  • sched_get_affinity() (查看当前的位掩码)

除此之外还提供了一些宏定义来修改掩码,如CPU_ZERO()(将位掩码全部设置为0)和CPU_SET()(设置特定掩码位为1)。

下面采用一个以@Eli Dow提供的程序为基础修改的程序介绍CPU亲和性的使用方法,该程序使用CPU亲和性API将N(CPU数量)个进程分别绑定到N个CPU上,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
#include <iostream>
#include <thread>
#include<stdlib.h>
#include<stdio.h>
#include<sys/types.h>
#include<sys/sysinfo.h>
#include<unistd.h> #define __USE_GNU
#include<sched.h>
#include<ctype.h>
#include<string.h>
#include<pthread.h> using namespace std; /* This method will create processes, then bind each to its own cpu. */
void do_cpu_stress(int num_of_process)
{
int created_process = 0;
/* We need a process for each cpu we have... */
while ( created_process < num_of_process - 1 )
{
int mypid = fork();
if (mypid == 0) /* Child process */
{
break;
}
else /* Only parent executes this */
{
/* Continue looping until we spawned enough processes! */ ;
created_process++;
}
}
/* NOTE: All processes execute code from here down! */
cpu_set_t mask;
/* CPU_ZERO initializes all the bits in the mask to zero. */
CPU_ZERO( &mask );
/* CPU_SET sets only the bit corresponding to cpu. */
CPU_SET(created_process, &mask );
/* sched_setaffinity returns 0 in success */
if( sched_setaffinity( 0, sizeof(mask), &mask ) == -1 ){
cout << "WARNING: Could not set CPU Affinity, continuing..." << endl;
}
else{
cout << "Bind process #" << created_process << " to CPU #" << created_process << endl;
}
//do some cpu expensive operation
int cnt = 100000000;
while(cnt--){
int cnt2 = 10000000;
while(cnt2--){
}
}
} int main(){
int num_of_cpu = thread::hardware_concurrency();
cout << "This PC has " << num_of_cpu << " cpu." << endl;
do_cpu_stress(num_of_cpu);
}

代码编译运行结果如下:

C++性能榨汁机之无锁编程

通过ps -eo pid,args,psr命令查看CPU与进程是否绑定成功:

C++性能榨汁机之无锁编程

可以看出,进程号6644的进程为父进程,该进程运行在CPU3上,6645、6646、6647三个子进程分别运行在CPU1、CPU2、CPU3上,可知进程与CPU绑定成功,进程只会运行在绑定的CPU上而不会被操作系统调度到其他CPU上。

CPU亲和性的应用场景

  • 假如某些进程需要高密度的计算,不希望被频繁调度,则可以使用CPU亲和性将该进程绑定到一个CPU上;
  • 在股票期货高频交易场景中,交易策略线程的运行时间关系到交易延迟的大小,而交易延迟1ms的差距可能就是赚钱与亏钱的差距,所以交易策略线程的优先级非常高,这时便可以为其分配一个专门用于策略计算的CPU,以避免线程被调度产生性能损失;
  • 高性能的Nginx采用多线程模型,并且提供了worker进程绑定固定CPU的功能,降低worker进程被调度的损耗,提高了服务器工作性能;
  • 一些文献中还提到了应用CPU亲和性优化KVM虚拟化技术的性能,在不减少吞吐量的情况下,可以将KVM的网络延迟性能降低20%;

总结

一般情况下,Linux系统的进程调度器已经做得足够好,不需要我们干预进程的调度,但是系统的进程调度是面向所有应用程序的,势必会为了通用性而牺牲掉一部分性能,但对于特定应用程序而言,我们可以通过CPU亲和性去优化程序的性能表现。

我们相对于计算机的优势就是我们知道我们的程序的功能、每个进程的重要程度,所以可以根据进程的重要程度更合理的分配计算机的CPU资源。

==================== End