【C++】类内使用多线程

std::thread

std::thread 是 C++ 11 引入的新特性,其使用也非常简单。由资源抢占所引发的加锁问题,使用 mutex 互斥量对公共变量施加保护固然可以有效地保障线程安全,但是这种方式的代价也会比较昂贵,其耗时似乎有点大;C++ 对此所引入的概念和 Java 很像,也就是原子操作 atomic。

void function(int n) {}    // 定义需要用线程挂起的函数
int n = 0;                 // 使用的参数
std::thread t(funtion, n); // 创建线程
t.join();                  // 阻塞主线程以等待子线程运行结束

在目前的简单分析看来,这个耗时性的影响与 mutex 锁所锁住的代码计算量密切相关,如果在一个耗时较短的且容易引发冲突的地方加锁对所线程计算时间的影响可能会小一些。互斥对象的使用,保证了同一时刻只有唯一的一个线程对这个共享进行访问,它保证了结果的正确性,但是也有非常大的性能损失。

引发资源抢占冲突的代码发生在 image2dataset() 函数中,现在发现的问题是,这个函数被写在了 cpp 中成了一个变相的全局函数,如果将它收归到 OSGB2GLTF 类中是否一切都会不一样嘞。

类与多线程

类内多线程需要用到 Lambda 表达式,亦即 λ \lambda λ 表达式;在用多线程 std::thread 调用时类内成员函数时要求其提供成员函数的实例,所以这个实例只能借助类似于 Q2 问题中的类外成员绑定的方式或直接在函数体内调用 λ \lambda λ 表达式的方式来予以传递。

Lambda 表达式

Lambda 表达式是一个基于数学中的 λ \lambda λ 演算(Lambda Calculus)得名的匿名函数,其直接对应于 λ \lambda λ 演算中的 Lambda abstraction,通常可以简单理解为一个没有函数名的函数。正统的 λ \lambda λ 演算是一个只包含单个参数的匿名函数,把任意多参数函数都转换成单参数的高阶函数的转换又叫做柯里化(Currying),命名的目的在于向数学家 Haskell Brooks Curry 致敬。该匿名函数柯里化的过程体现了 Lambda 表达式创始人 Alonzo Church 为了简化函数概念而付出的努力。一个计算两个数平方和的 Currying 形式如下:
x ↦ ( y ↦ x × x + y × y ) x\mapsto(y\mapsto x\times x+y\times y) x(yx×x+y×y)
退一步讲,写成含两个参数的映射的形式可以写为:
( x , y ) ↦ x × x + y × y (x,y)\mapsto x\times x+y\times y (x,y)x×x+y×y
这样,我们可以再退一步,得到常见的二元函数的形式,也即:
f ( x , y ) = x × x + y × y f(x,y)=x\times x+y\times y f(x,y)=x×x+y×y
事实上, λ \lambda λ 演算的语法只有三类表达式:函数定义、标识符引用以及函数应用。函数定义: λ \lambda λ 演算中的函数是一个表达式,写成:lambda x . <函数体>,表示“一个参数参数为 x 的函数,它的返回值为函数体的计算结果” 这时我们说:Lambda 表达式绑定了参数 x。标识符引用:标识符引用就是一个名字,这个名字用于匹配函数表达式中的某个参数名。函数应用:函数应用写成把函数值放到它的参数前面的形式,如(lambda x . plus x x) y。此时,我们可以讲计算两个数平方和的 Lambda 表达式写成如下形式:
λ   x   .   ( λ   y   .   ( t i m e s   x   x )   p l u s   ( t i m e s   y   y ) ) \lambda\ x\ .\ \left(\lambda\ y\ .\ \left(\mathrm{times}\ x\ x\right)\ \mathrm{plus}\ \left(\mathrm{times}\ y\ y\right)\right) λ x . (λ y . (times x x) plus (times y y))
λ \lambda λ 演算伟大的的原因有很多,其中包括:非常简单;图灵完备;容易读写;语义足够强大,可以从它开始做任意推理;有一个很好的实体模型;容易创建变种,便于探索各种构建计算或语义方式的属性等。 λ \lambda λ 演算易于读写,这一点很重要。它导致人们开发了很多极为优秀的编程语言,他们在不同程度上都基于 λ \lambda λ 演算。

图灵机是计算机的老祖先,虽然简单却可以用来模拟任何算法。图灵机的设计者 Alan Mathison Turing 将图灵机描述为对人们使用纸和笔进行数学计算的过程的抽象,目的是让机器代替人类进行数学计算。

图灵机于1936年被设计出来,那时的它主要由无限长的纸袋读写头控制器三部分组成:图灵机把抽象过程过程看作下列两种简单的动作:在纸上写上或擦除某个符号;把注意力从纸的一个位置移动到另一个位置;而在每个阶段,人要决定下一步的动作,依赖于(a)此人当前所关注的纸上某个位置的符号和(b)此人当前思维的状态。

常见的 C++ 的 Lambda 表达式有如下两种:

[外部变量访问方式说明符](参数表)mutable->返回值类型 {函数体}
[外部变量访问方式说明符](参数表){函数体}

微软官方文档中绘制了如下示意图,

【C++】类内使用多线程_第1张图片

其中,

  1. 外部变量访问方式说明符,即 Capture Clause 捕获子句;
    []不访问封闭范围内的变量;
    [&]以引用形式捕获所有外部变量;
    [=]以值拷贝的形式捕获所有外部变量;
    [=, &foo]以引用形式捕获 foo 变量,其余变量均采用值拷贝的形式予以捕获;
    [bar]以值拷贝的形式捕获 bar 变量,其余变量均不予捕获;
    [this] 捕获封闭范围内的 this 指针变量;
  2. 函数参数列表,除了捕获变量,lambda 还可接受输入参数; 标准语法中的参数是可选的,在大多数情况下与函数的参数列表类似;
  3. 可变规范,可有可无mutable关键字用以说明表达式体内的代码可以修改值捕获的变量;用可变规范,Lambda 表达式的主体可以修改通过值捕获的变量;
  4. 异常规范,可有可无,可以使用 noexcept 异常规范来指示 lambda 表达式不会引发任何异常。 与普通函数一样,如果 lambda 表达式声明 noexcept 异常规范且 lambda 体引发异常,Microsoft C++ 编译器将生成警告 C4297;
  5. 尾部返回类型,可有可无,使用 auto 时将自动推导 Lambda 表达式的返回类型,此时不需要使用返回类型关键字; 尾部返回类型类似于普通方法或函数的返回类型部分,但是匿名函数的返回类型必须跟在参数列表的后面,且必须在返回类型前面添加尾部返回类型 -> 关键字 。
  6. Lambda函数体,函数需要执行的主体代码。

类内成员调用

后面又了解到类内成员函数的多线程执行方式其实一共有三种:静态/非静态成员函数Lambda表达式以及与前文所介绍的第三种回调函数调用方式相似的函数绑定

[1] 静态/非静态成员函数

C++11标准中关于多线程的核心在于std::thread类的构造函数所指定的函数指针。因此,有两种方式实现类内成员函数的调用,其一是将成员函数指定为静态的,其二是将成员函数声明为非静态的并传入 this 指针:

方法1:std::thread(&Class::function),其中functionstatic的;
方法2:std::thread(&Class::fucntion, this),其中fucntion为非静态的。

通过静态成员函数执行多线程的具体方案如下:

class Cat{
public:
    /* 喵喵的品种. */
    typedef enum _cat{
        SIAMESE, // 暹罗喵
        RAGDOLL  // 布偶喵
    } CatType;
public:
    /* 饲养员的名字. */
    void feeder(std::string name){ _feeder = name; }
    /* 点个名检查一下需要投喂哪些猫. */
    void rollcall(CatType type){
        switch(type){
        case SIAMESE: {
            std::thread* cat = new std::thread(&Cat::SiameseCat);
            _cats.push_back(cat);break;}
        case RAGDOLL: {
            std::thread* cat = new std::thread(&Cat::RagdollCat);
            _cats.push_back(cat);break;}
        }
    }
    /* 投食机器开始并行投喂. */
    void feed(){ for(auto cat : _cats) cat->join(); }
private:
    /* 投喂暹罗猫的静态成员函数. */
    static void SiameseCat() { 
        printf(">> Feeding siamese cat...\n"); 
        // 加一个时延,这样在多线程中暹罗将后被投喂完
        std::this_thread::sleep_for(std::chrono::milliseconds(10));
        printf("   Siagmese cat is full.\n"); 
    }
    /* 投喂布偶猫的静态成员函数. */
    static void RagdollCat() { 
        printf(">> Feeding rogdall cat...\n"); 
        printf("   Rogdall cat is full.\n"); 
    }
private:
    /* 喵喵数组. */
    std::vector<std::thread*> _cats;
    /* 饲养员. */
    std::string _feeder;
};

静态成员函数的缺陷是,除非将成员变量声明为静态的,否则在线程函数体内无法使用类内的成员变量。为了进一步完善这一缺陷,可以使用 Lambda 匿名函数表达式或者 C++11 的函数绑定特性。

[2] Lambda 表达式

前文已经阐述过 C++ 的 Lambda 匿名函数表达式的基本用法,现在来介绍它是如何使得上面的多线程可以在每个线程里调用饲养员的名字的,这里假定两只喵都由同一个饲养员喂养。

class Cat{
public:
    typedef enum _cat{
        SIAMESE,
        RAGDOLL
    } CatType;
public:
    void feeder(std::string name){ _feeder = name; }
    void rollcall(CatType type){
        switch(type){
        case SIAMESE: {
            /* 投喂暹罗喵的匿名函数. */
            std::thread* cat = new std::thread([=](){
				printf(">> %s is feeding siamese cat...\n", _feeder.c_str());
                std::this_thread::sleep_for(std::chrono::milliseconds(10));
                printf("   Siagmese cat is full.\n"); 
			});_cats.push_back(cat);break;}
        case RAGDOLL: {
            /* 投喂布偶喵的匿名函数. */
            std::thread* cat = new std::thread([=](){
			    printf(">> %s is feeding ragdoll cat...\n", _feeder.c_str());
                printf("   Rogdall cat is full.\n"); 
			});_cats.push_back(cat);break;}
        }
    }
    void feed(){ for(auto cat : _cats) cat->join(); }
private:
    std::vector<std::thread*> _cats;
    std::string _feeder;
};

这样一来,就可以在不声明静态成员函数的前提下使用多线程喂猫了。不过需要注意的是,虽然可以不用声明成员函数了,但这个 Lambda 表达式仍然需要声明外部成员变量访问方式修饰符。

[3] 函数绑定

前面说到的方法都是指在类内创建多线程调用类内的成员函数或成员变量,多线程的开启或终止对类外的使用者来说是不可见的,而函数绑定这种方式可以实现类外成员函数的绑定,也可以实现静态成员函数调用类内成员变量的需求,当然静态成员函数调用非静态成员变量的万剑归宗就是引用类的实例。

class Cat{
public:
    /* 喵喵的品种. */
    typedef enum _cat{
        SIAMESE, // 暹罗喵
        RAGDOLL  // 布偶喵
    } CatType;
public:
    /* 饲养员的名字. */
    std::string& feeder(){ return _feeder; }
    /* 点个名检查一下需要投喂哪些猫. */
    void rollcall(CatType type){
        switch(type){
        case SIAMESE: {
            std::thread* cat = new std::thread(std::bind(&Cat::SiameseCat, this));
            _cats.push_back(cat);break;} 
        case RAGDOLL: {                  
            std::thread* cat = new std::thread(std::bind(&Cat::RagdollCat, this));
            _cats.push_back(cat);break;}
        }
    }
    /* 投食机器开始并行投喂. */
    void feed(){ for(auto cat : _cats) cat->join(); }
private:
    /* 投喂暹罗猫的静态成员函数. */
    static void SiameseCat(Cat* cat) { 
        printf(">> %s is feeding siamese cat...\n", cat->_feeder.c_str()); 
        // 加一个时延,这样在多线程中暹罗将后被投喂完
        std::this_thread::sleep_for(std::chrono::milliseconds(10));
        printf("   Siagmese cat is full.\n"); 
    }
    /* 投喂布偶猫的静态成员函数. */
    static void RagdollCat(Cat* cat) { 
        printf(">> %s is feeding rogdall cat...\n", cat->_feeder.c_str()); 
        printf("   Rogdall cat is full.\n"); 
    }
private:
    /* 喵喵数组. */
    std::vector<std::thread*> _cats;
    /* 饲养员. */
    std::string _feeder = "Default";
};

用循环创建多线程

for 循环创建多线程时,要注意到 std::thread::join() 函数发生阻塞的位置,该函数的目的在于阻塞主线程的执行,以便等待子线程的处理结果处理结束之后再来执行主线程的后续方法。所以一般的解决策略是在循环体内 new 一个 std::thread 指针出来放到容器中,并在循环结束后对容器中存储的所有线程指针执行接续的 std::thread::join() 阻塞,当然用 new 创建的指针在结束后也必须予以释放。

std::list<std::thread*> threads; // 创建一个存储 std::thread 指针的容器
for (int i = 0; i < 10; i++) { // 执行并行操作
    std::thread* thread_new = new std::thread(fun, arg1, arg2);
    threads.push_back(thread_new);
    // 不能在此处 join(),否则执行过程与串行一致,就白费功夫了
}
for (auto thread : threads) { thread->join(); } //阻塞主线程等待子线程执行完毕
for (auto thread : threads) { delete thread; }  //创建的内存用完后销毁

参考文献

  1. CSDN博客.C++ 多线程编程之在类中使用多线程(thread)的方法[EB/OL].
  2. 菜鸟教程.C++ std::thread[EB/OL].
  3. 知乎.什么是 Lambda 演算[EB/OL].
  4. CSDN博客.C++多线程初探:thread、atomic及mutex的配合使用[EB/OL].
  5. Microsoft.C++ 中的 Lambda 表达式[EB/OL].

你可能感兴趣的:(C++编程问题,c++,多线程)