Modern C++(二)C++并行编程

C++11之前

在C++11之前,在C/C++中程序中使用线程却并非鲜见。这样的代码主要使用POSIX线程(Pthread)和OpenMP编译器指令两种编程模型来完成程序的线程化。其中,POSIX线程是POSIX标准中关于线程的部分,程序员可以通过一些Pthread线程的API来完成线程的创建、数据的共享、同步等功能。Pthread主要用于C语言,在类UNIX系统上,如FreeBSD、NetBSD、OpenBSD、GNU/Linux、Mac OS X,甚 至 是Windows上 都有 实 现(Windows上Pthread的实现并非“原生”,主要还是包装为Windows的线程库)。不过在使用的便利性上,Pthread不如后来者OpenMP。OpenMP的编译器指令将大部分的线程化的工作交给了编译器完成,而将识别需要线程化的区域的工作交给了程序员,这样的使用方式非常简单,也易于推广。
pthread并行编程例子:

#include <pthread.h>
#include <iostream>
using namespace std;
static long long total = 0;
pthread_mutex_t m = PTHREAD_MUTEX_INITIALIZER;
void* func(void *) {
    long long i;
    for(i = 0; i < 10LL;i++) {
        pthread_mutex_lock(&m);
        total += i;
        cout << "thread id:" << pthread_self() << " total:" << total << endl; // 两个线程交替执行
        pthread_mutex_unlock(&m);
    }
}

int main() {
    pthread_t thread1, thread2;
    if (pthread_create(&thread1, NULL, &func, NULL)){
        throw;
    }
    if (pthread_create(&thread2, NULL, &func, NULL)){
        throw;
    }
    pthread_join(thread1, NULL);
    pthread_join(thread2, NULL);
    cout << total << endl;
    return 0;
    }
    // 编译选项:g++ main.cpp -lpthread
    //pthread是第三方库,需要进行显式链接

C++11,多线程支持

C++11中,C++自带了对多线程的支持,还引入了原子操作和原子类型。
在这之前想要做到互斥,必须使用汇编或者pthread的互斥锁mutex

原子类型

原子操作是指该操作是最小的,不可中断的操作。C++11中定义了原子类型,原子类型是一些数据的抽象,对于这些数据的操作被称为原子操作,也就是说,对于原子类型的操作是无法中断的。
直观地看,编译器可以保证原子类型在线程间被互斥地访问。这样设计,从并行编程的角度看,是由于需要同步的总是数据而不是代码,因此C++11对数据进行抽象,会有利于产生行为更为良好的并行代码。而进一步地,一些琐碎的概念,比如互斥锁、临界区则可以被C++11的抽象所掩盖,因而并行代码的编写也会变得更加简单。

C++11和C11都支持原子类型,例子如下:

#include <iostream>
#include <atomic>
#include <thread>


int main(){
	//程序员可以使用atomic类模板。通过该类模板,程序员任意定义出需要的原子类型。
	//也可以使用内置的类型,例如:std::atomic_ulong,std::atomic_int
    std::atomic<long long> total(0); 
    auto func = [&total](){
        for(auto i=0;i<10;i++){
            total += i;
            std::cout << "total:" << total << std::endl;
        }
    }; // lambda表达式, 用于定义匿名函数
    std::thread t1(func);
    std::thread t2(func);
    t1.join(); 
    //join()即等待线程执行完成再接着执行,
    // detach(),启动的线程自主在后台运行,当前的代码继续往下执行,不等待新线程结束。
    t2.join();
    return 0;
}

C11版本:

#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <stdatomic.h>

static _Atomic long long total = 0;
//使用_Atomic关键字修饰即可

void* func(void *arg) {
    long long i;
    for(i = 0; i < 10LL;i++) {
        total += i;
        printf("thread id:%lu total:%lld\n", pthread_self(), total);
    }
}

int main() {
    pthread_t thread1, thread2;
    if (pthread_create(&thread1, NULL, &func, NULL)){
        perror("pthread_create");
        exit(1);
    }
    if (pthread_create(&thread2, NULL, &func, NULL)){
        perror("pthread_create");
        exit(1);
    }
    pthread_join(thread1, NULL);
    pthread_join(thread2, NULL);
    printf("%lld\n", total);
    return 0;
}
  • 在C++11中,原子类型只能从其模板参数类型中进行构造,标准不允许原子类型进行拷贝构造、移动构造,以及使用operator=等,以防止发生意外。atomic模板类的拷贝构造函数、移动构造函数、operator=等总是默认被删除的。
  • 从atomic类型的变量来构造其模板参数类型T的变量则是可以的。
std::atomic<int> atomic_i {1};
int i = atomic_i;
int i1 = {atomic_i};
  • 在C++11中,标准将原子操作定义为atomic模板类的成员函数,这囊括了绝大多数典型的操作,如读、写、交换等。当然,对于内置类型而言,主要是通过重载一些全局操作符来完成的。

使用原子类型可以实现自旋锁

自旋锁就是当线程拿不到锁就原地打转等待,就称为自旋锁

#include <thread>
#include <atomic>
#include <iostream>
#include <unistd.h>
using namespace std;
std::atomic_flag lock = ATOMIC_FLAG_INIT;
void f(int n) {
    while (lock.test_and_set(std::memory_order_acquire)) // 尝试获得锁
        cout << "Waiting from thread " << n << endl;      // 自旋
    cout << "Thread " << n << " starts working" << endl;
}
void g(int n) {
    cout << "Thread " << n << " is going to start." << endl;
    lock.clear();
    cout << "Thread " << n << " starts working" << endl;
}
int main() {
    lock.test_and_set();
    thread t1(f, 1);
    thread t2(g, 2);
    t1.join();
    usleep(100);
    t2.join();
}
// 编译选项:g++ -std=c++11 main.cpp -lpthread

C++11中也定义了锁mutx这个互斥量。std::mutex 是C++11 中最基本的互斥量,std::mutex 对象提供了独占所有权的特性——即不支持递归地对 std::mutex 对象上锁,而 std::recursive_lock 则可以递归地对互斥量对象上锁。

mutex常用操作:
lock():资源上锁
unlock():解锁资源
trylock():查看是否上锁,它有下列3种类情况:
(1)未上锁返回false,并锁住;
(2)其他线程已经上锁,返回true;
(3)同一个线程已经对它上锁,将会产生死锁。

可以写一个矩阵加法的例子看一下区别

#include <iostream>
#include <thread>
#include <mutex>
#include <atomic>

void mutex_add(int matrix[1000][1000], std::mutex& mtx){
    for(auto i=0;i<1000;i++){
        mtx.lock();
        for(auto j=0;j<1000;j++){
            matrix[i][j] += 1;
        }
        mtx.unlock();
    }
}

void atomic_add(std::atomic<int> matrix[1000][1000]){
    for(auto i=0;i<1000;i++){
        for(auto j=0;j<1000;j++){
            matrix[i][j] += 1;
        }
    }
}

void add(int matrix[1000][1000]){
    for(auto i=0;i<1000;i++){
        for(auto j=0;j<1000;j++){
            matrix[i][j] += 1;
        }
    }
}

int main(){
    int matrix[1000][1000] = {0};
    std::mutex mtx; //初始化一个互斥量
    auto start = std::chrono::steady_clock::now();

    std::thread t1(mutex_add, matrix, std::ref(mtx));
    std::thread t2(mutex_add, matrix, std::ref(mtx));
    t1.join();
    t2.join();
    
    //C++11中时间处理的部分
    auto end = std::chrono::steady_clock::now(); 
    auto dt = end-start;
    int64_t duration = std::chrono::duration_cast<std::chrono::milliseconds>(dt).count();
    std::cout << "mutx run time:" << duration << std::endl;

    std::atomic<int> atomic_matrix[1000][1000] = {0};
    start = std::chrono::steady_clock::now();
    std::thread t3(atomic_add, atomic_matrix);
    std::thread t4(atomic_add, atomic_matrix);
    t3.join();
    t4.join();
    end = std::chrono::steady_clock::now();
    dt = end-start;
    duration = std::chrono::duration_cast<std::chrono::milliseconds>(dt).count();
    std::cout << "atomic run time:" << duration << std::endl;

    start = std::chrono::steady_clock::now();
    add(matrix);
    add(matrix);
    end = std::chrono::steady_clock::now();
    dt = end-start;
    duration = std::chrono::duration_cast<std::chrono::milliseconds>(dt).count();
    std::cout << "serial run time:" << duration << std::endl;
    return 0;
}

内存模型

内存模型通常是一个硬件上的概念,表示的是机器指令(或者读者将其视为汇编语言指令也可以)是以什么样的顺序被处理器执行的。按照顺序执行的被称为强顺序内存模型,可以将代码顺序打乱的称为弱顺序内存模型。现代的处理器并不是逐条处理机器指令的。
在现实中,x86以及SPARC(TSO模式)都被看作是采用强顺序内存模型的平台。对于任何一个线程而言,其看到原子操作(这里都是指数据的读写)都是顺序的。而对于是采用弱顺序内存模型的平台,比如Alpha、PowerPC、Itanlium、ArmV7这样的平台而言,如果要保证指令执行的顺序,通常需要由在汇编指令中加入一条所谓的内存栅栏(memory barrier)指令。比如在PowerPC上,就有一条名为sync的内存栅栏指令。该指令迫使已经进入流水线中的指令都完成后处理器才执行sync以后指令(排空流水线)。这样一来,sync之前运行的指令总是先于sync之后的指令完成的。sync指令对高度流水化的PowerPC处理器的性能影响很大,因此,如果可以不顺序提交语句的运行结果的话,则可以保证弱顺序内存模型的处理器保持较高的流水线吞吐率(throughput)和运行时性能。

RISC-V使用的是称为RVWMO弱内存模型,为了便于从x86体系结构向RISC-V迁移,RISC-V规范还明确了一个称为“Ztso”的标准扩展,提供完全兼容x86架构的RVTSO(RISC-V Total Store Ordering)内存模型。

高级语言中的内存模型是指是否运行编译器在编译过程中对代码顺序进行改动。C++中的操作原子类型变量的代码默认为强顺序模型,在线程中保持着顺序一致性,即:代码在线程中的运行顺序与程序员看到的代码顺序一致。

atomic<int> a,b;
int t = 1;
a = t;
b = 2;
//a和b的赋值顺序就是按照先a后b执行

但是有时候前后代码并没有关联,可以通过超标量流水线进行优化同时执行,那就需要用到别的内存模型了。C++11中程序员可以为原子操作指定内存顺序。

#include <thread>
#include <atomic>
#include <iostream>
using namespace std;
atomic<int> a {0};
atomic<int> b {0};
int ValueSet(int) {
    int t = 1;
    a.store(t, memory_order_relaxed); //第二个参数设置内存模型,是一个枚举类型
    b.store(2, memory_order_relaxed); //memory_order_relaxed是指允许乱序,memory_order_seq_cst是指强顺序
}
int Observer(int) {
    cout << "(" << a << ", " << b << ")" << endl;    // 可能有多种输出
}
int main() {
    thread t1(ValueSet, 0);
    thread t2(Observer, 0);
    t1.join();
    t2.join();
    cout << "Got (" << a << ", " << b << ")" << endl;    // Got (1, 2)
    return 0;
}
// 编译选项:g++ -std=c++11 main.cpp -lpthread

线程局部存储TLS, thread local storage

线程局部存储变量,就是拥有线程生命期及线程可见性的变量。线程局部存储实际上是由单线程程序中的全局/静态变量被应用到多线程程序中被线程共享而来。

int thread_local x = 0; //C++11规定声明方式

__thread int y = 0; //g++,clang++自带的声明方式

一旦声明一个变量为thread_local,其值将在线程开始时被初始化,而在线程结束时,该值也将不再有效。对于thread_local变量地址取值(&),也只可以获得当前线程中的TLS变量的地址值。

退出

  • termiante函数:异常处理中的退出函数,包含在头文件里。terminate函数在默认情况下,是去调用abort函数的。不过用户可以通过set_terminate函数来改变默认的行为。
  • abort函数:源自于C中(头文件)的abort则更加低层。abort函数不会调用任何的析构函数,默认情况下,它会向合乎POSIX标准的系统抛出一个信号(signal):SIGABRT。如果程序员为信号设定一个信号处理程序的话(signal handler),那么操作系统将默认地释放进程所有的资源,从而终止程序。可以说,abort是系统在毫无办法下的下下策—终止进程。
  • exit函数:属于“正常退出”范畴的程序终止,exit函数会正常调用自动变量的析构函数,并且还会调用atexit注册的函数。这跟main函数结束时的清理工作是一样的。可以配合atexit函数灵活地处理一些进程级的清理工作,这对一些静态、全局变量来说,是非常有用的。atexit函数表示声明在进行exit时需要自动调用的函数

在C++11中,标准引入了quick_exit函数,该函数并不执行析构函数而只是使程序终止。与abort不同的是,abort的结果通常是异常退出(可能系统还会进行coredump等以辅助程序员进行问题分析),而quick_exit与exit同属于正常退出。此外,使用at_quick_exit注册的函数也可以在quick_exit的时候被调用。这样一来,我们同样可以像exit一样做一些清理的工作(这与很多平台上使用_exit函数直接正常退出还是有不同的)。在C++11标准中,at_quick_exit和at_exit一样,标准要求编译器至少支持32个注册函数的调用。

可以用于解除因为退出造成的死锁等不良状态,也可以尝试着使用它来免除大量的不必要的析构函数调用。
有一些类在堆上面分配了零散内存,调用析构会将这些内存一一析构,较为费时,实际上压根不用析构,程序结束时操作系统自动就回收了,在数据页表项上标记为未使用即可

例子:

#include <cstdlib>
#include <iostream>
using namespace std;
struct A { 
    ~A() { cout << "Destruct A. " << endl; } 
};

void closeDevice() { 
    cout << "device is closed." << endl; 
}

int main() {
    A a;
    at_quick_exit(closeDevice);
    quick_exit(0); //A不会析构
}

总结

C++11对多线程的支持:

  • 在std中实现了多线程支持
  • 实现了原子操作和原子类型,降低编程难度
  • 实现了不同内存模型,方便追求极限性能
  • 实现了quick_exit,方便解决卡死情况。
  • 8
    点赞
  • 30
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值