Part.3 重修C++之并发实战1-2

1 开始入门

让我们开始学习C++的多线程编写,C++!C++!C++!不是C,想看C的盆友请转到哦之前的文章 二、多线程_Linux C ,准确来说在 C++ 中使用 C 的多线程方法写也是没有问题的,而其本质上C++的多线程也是用到C的多线程技术。但是为了更好的学习C++,还是尽量在C++中使用C++标准的多线程模型。(PS:看书上说以前C++还不支持多线程模型。。。)

你好并发世界

首先先使用一个多线程写一个 “Hello World!”

HelloConcurrentWorld.cpp

#include <iostream>
#include <thread>void hello()
{
    std::cout << "Hello Concurrent World!" << std::endl;
}
int main(int argc, const char** argv) 
{
    std::thread t(hello);
    t.join();
    return 0;
}
复制代码

打开Terminal

[wangs7@localhost HelloConcurrentWorld]$ g++ -o HelloConcurrentWorld HelloConcurrentWorld.cpp -pthread
[wangs7@localhost HelloConcurrentWorld]$ ./HelloConcurrentWorld 
Hello Concurrent World!
[wangs7@localhost HelloConcurrentWorld]$ 
复制代码

好了,这就入门了。

2 管理线程

线程是通过构造 std::thread 对象开始,该对象指定了线程上需要运行的任务(函数hello)。当然C++的多线程模型需要引入 <thread> 来支持,并且允许将一个带有函数调用操作符的类的实例传递给 std::thread 的构造函数进行代替。下面是几种构造形式。

2.1 启动线程

#include <iostream>
#include <thread>

void do_something()
{
    std::cout << "do_something()" << std::endl;
}
void do_something_else()
{
    std::cout << "do_something_else()" << std::endl;
}
class background_task
{

public:
    void operator()() const //函数调用操作符
    {
        do_something();
        do_something_else();
    }
};

int main(int argc, const char** argv) {
    
    // 启动线程
    // 额外的括号避免其解释为函数声明
    std::thread thread1( /* 额外的括号 */(background_task())/* 额外的括号 */ );
    
    // 新的统一的初始化语法,用大括号而不是括号
    std::thread thread2{background_task()}; 
    //等待线程回收
    thread1.join();
    thread2.join();
    return 0;
}
复制代码
  • 当线程仍然访问局部变量时返回的函数
#include <iostream>
#include <thread>
#include <unistd.h>

void do_something(int& i)
{
    std::cout << i++ << "::" << "do_something()" << std::endl;
}

class func
{
public:
    int& i;
    func(int& i_):i(i_) {}
    void operator()() const
    {
        for (int j = 0; j < 1000000; j++)
        {   
            std::cout << j << "::";
            do_something(i); // 1
        }
        
    }
};

void oops()
{
    int stat = 0;
    func myfunc(stat);
    std::thread my_thread(myfunc);
    my_thread.detach(); //分离线程,即不等待线程完成
    usleep(10); //加休眠是为了方便观察循环次序和变量值的变化
}// 当oops()退出时,线程仍然可能运行,1出就会访问一个被销毁的变量

int main(int argc, const char** argv) {
    oops();
    usleep(10);
    return 0;
}
复制代码

运行后发现最后打印的 j 和 i 的值不同,向上找记录,找到130和131行发现:

126::126::do_something()
127::127::do_something()
128::128::do_something()
129::129::do_something()
130::130::do_something() //j 和 i 一致
131::0::do_something() //j 和 i 开始不一样
132::1::do_something()
133::2::do_something()
134::3::do_something()
135::4::do_something()
复制代码

所以这里就是发生错误引用的地方

2.2 等待线程完成

等待线程完成需要调用join()这样就能确保在函数结束前等待该线程结束,join()的方式简单暴力,要么就等一个线程完成,要么就不等。如果需要对线程进行更细粒度的控制,例如检查线程是否完成,或只是在一段特定时间内进行等待,就必须使用替代机制,例如条件变量和future。调用join()的行为会清理所有与该线程相关联的存储器,这样std::thread对象不再与现在已完成的线程相关联,它也不与任何线程相关联,这就意味着,你只能对一个给定的线程调用一次join(),一旦调用了join(),此std::thread对象就不再是可连接的,并且joinable()将返回false

  • 在异常环境下的等待

在使用多线程的时候,我们需要保证在线程对象销毁前调用join()detach()函数。如果要分离线程,通常在线程启动后即可分离,但是如果打算等待该线程就需要仔细地选择在代码地那个位置调用join()。如果在join()前发生异常,就很可能跳过join(),所以就需要在异常处理中也添加join()调用,使用try/catch是很麻烦的事情。

所以,有一种标准地资源获取即初始化(RAII)惯用语法,并提供一个类,在它的析构函数中进行join(),如下:

#include <iostream>
#include <thread>
#include <unistd.h>
//用来承载线程的类
class ThreadGuard
{
    std::thread& t;
public:
    //构造函数 要用引用而不是值传递!
    ThreadGuard(std::thread &t_):t(t_) {}
    //析构函数 保证对象销毁前等待线程退出
    ~ThreadGuard()
    {
        if (t.joinable())
        {
            t.join();
        }
    }
    //销毁const的拷贝函数和赋值函数
    ThreadGuard(ThreadGuard const &) = delete;
    ThreadGuard &operator=(ThreadGuard const &) = delete;
private:
     //私有化拷贝函数和赋值函数 保证外部不会调用
    ThreadGuard(ThreadGuard &&) = default;
    ThreadGuard &operator=(ThreadGuard &&) = default;

};

//打印函数
void do_something(int& i)
{
    std::cout << i++ << "::" << "do_something()" << std::endl;
}
//循环打印函数并且计数 将要在线程中执行的类
class func
{
public:
    int& i;
    func(int& i_):i(i_)
    {

    }
    void operator()() const //()运算符函数 线程执行函数
    {
        std::cout << "thread start--------------------." << std::endl;
        for (int j = 0; j < 100; j++)
        {   
            std::cout << j << "::";
            do_something(i);
            usleep(2);
        }
        
    }
};

//初始化线程 并且执行
void oops()
{
    int stat = 0;
    //初始化func类
    func myfunc(stat);
    //启动线程
    std::thread my_thread(myfunc);
    //将线程添加到 ThreadGuard 类中
    ThreadGuard g(my_thread);
    std::cout << "start usleep in thread." << std::endl;
    usleep(5);
    std::cout << "stop usleep in thread." << std::endl;
} //结束线程对象的生命周期,由于 ThreadGuard 会等待线程退出


int main(int argc, const char** argv) {

    oops();
    std::cout << "main-------------------." << std::endl;
    usleep(10);
    return 0;
}
复制代码

执行情况

[wangs7@localhost 2nd_chapter]$ ./a
start usleep in thread.
thread start--------------------.
0::0::do_something()
stop usleep in thread.
1::1::do_something()
2::2::do_something()
3::3::do_something()
4::4::do_something()
... ... ...
... ... ...
98::98::do_something()
99::99::do_something()
main-------------------.
[wangs7@localhost 2nd_chapter]$ 
复制代码

首先可以看出在打印 “stop usleep in thread.” 之后(不是立刻)应该就会退出 oop() 线程应该会直接挂掉,但是由于 ThreadGuard 类中析构的时候会等待线程,是所以最后会一直等到线程退出,才进入主函数。

2.3 在后台运行线程

std::thread 对象上调用 detach() 会把线程丢到后台运行,没有直接的方法与之通信。也不再可能等待该线程完成;如果一个线程成为分离的,获取一个引用它的 std::thread 对象也是不可能的,所以它也不能够再次被结合。分离的线程会在后台运行;所有权和控制权会被转交给C++运行时库,以确保与线程相关联的资源在线程退出后能够被正确地回收。

2.4 传递参数给线程函数

对于在线程中传递参数给调用的函数或对象,基本上就是简单地将额外复制的参数传递给线程对象的构造函数。但重要的是,参数会以默认的方式被复制到内部存储空间,在哪里新创建的执行线程可以访问它们,即便函数中的相应参数期待着引用,下面提供一个简单的例子

void f(int i, std::string const& s);
std::thread t(f, 3, "hello");
复制代码

这里创建一个与 t 相关联的执行线程,称为 f(3, "hello") 。即使 f 的第二个参数接受一个 std::string const& 类型的参数,但是字符串字面值仅在新线程的上下文中才会作为 char const* 传送,并作为 std::string 。尤其重要的是当提供的参数是一个自动变量指针时,如下。

void f(int i, std::string const& s);
void oops(int some_param)
{
    char buffer[1024];
    sprintf(buffer, "%i", some_param);
    //有可能出现 在buffer转换成string类型之前函数oops退出,导致未定义的情况
    //大概率会发上述情况,解决方法是在将buffer传递给线程构造函数之前就完成类型转换
    std::thread t(f, 3, buffer);
    
    t.detach();
}
//解决方法 提前转换类型
    std::thread t(f, 3, std::string(buffer));
复制代码

上述方法完成的仅是**“复制”**即使参数中带有引用的符号,在线程构造过程中也是仅仅复制出一个对象放到线程中而非引用,如果期望使用引用,希望改变传入的参数,就需要使用 std::ref 来包装需要被引用的参数。下面的n将正确地被传入引用,而非n的副本。

void f2(int& n);
std::thread t2(f2, std::ref(n)); // 按引用传递
复制代码

除了前面的几种构造方法之外,还有下面这种形式

class X
{
public:
    void do_lengthy_work();
};
X my_x;
// 在 my_x 对象上运行 X::do_lengthy_work()
std::thread t(&X::do_lengthy_work, &my_x);
// 这段代码将在新线程上调用 my_x.do_lengthy_work()
// 而且第三个参数之后的参数将会作为 do_lengthy_work() 的参数
复制代码

除此之外,还有另一种传递参数的方式,这里的参数只能够被**移动(一个对象内保存的数据被转移到另一个对象,使原来的对象变为空壳)**而不能被复制。

这种类型的一个例子是 std::unique_ptr 它提供了动态分配对象的自动内存管理。只有一个 std::unique_ptr 实例可以在某一时刻指向一个给定的对象,当该实例销毁时,其指向的对象将被删除。移动构造函数移动赋值运算符允许一个对象的所有权在 std::unique_ptr 实例之间进行转移,这种转移会给源对象留下一个空指针。 所以当线程使用这种对象为参数时只能选择移动。

void f3(std::unique_ptr<big_object> b);
std::unique_ptr<big_object> p(new big_object);
p->prepare_data(42);
std::thread t3(f3, std::move(p));
复制代码

2.5 转移线程所有权

std::threadstd::unique_ptr 是一样的,都是可移动的,而非可复制的。这意味着线程的所有权可以转移但是不能够被复制。

void some_function();
void some_other_function();
// t1关联执行线程some_function()
std::thread t1(some_function);
// 当t2构建完成时some_function()线程所有权由t1转移到t2
std::thread t2 = std::move(t1);
// 启动一个新线程并与临时的std::thread对象相关联,并将所有权转移给t1
t1 = std::thread(some_other_function);
// 创建一个std::thread对象t3不关联任何线程
std::thread t3;
// 线程所有权的相互转移
t3 = std::mve(t2);
t1 = std::mve(t3);
复制代码

因为 std::thread 支持移动,所以线程的所有权很容易从一个函数中被转出,或被转入。

std::thread func_out()
{
    std::thread t(...);
    ... ...
    return t;
}

void func_in(std::thread t);
func_in(std::thread(...)); //or
func_in(std::move(t)); 
复制代码

std::thread 支持移动的好处之一就是可以实际获取线程的所有权。这可以避免引用它的线程结束后继续存在造成不良影响,同时也意味着一旦所有权转移到了该对象,那么其他对象都不可以结合或分离该线程。因为这主要是为了确保在退出一个作用域之前线程都已完成,这种类称为 scoped_thread

#include <iostream>
#include <thread>

class scoped_thread
{
    std::thread t;
public:
    explicit scoped_thread(std::thread t_):t(std::move(t_))
    {
        if (!t.joinable())
        {
            throw std::logic_error("No thread");
        }
    }

    virtual ~scoped_thread()
    {
        t.join();
    }

    scoped_thread(scoped_thread &&) = delete;
    scoped_thread(const scoped_thread &) = delete;
    scoped_thread &operator=(scoped_thread &&) = delete;
    scoped_thread &operator=(const scoped_thread &) = delete;

};

void fun(int state)
{
    for (int i = 0; i < 1000; i++)
        std::cout << "Now in fun, state is " << i << "::" << state << std::endl;
}

void f()
{
    int some_local_state = 666;
    scoped_thread t(std::thread(fun, some_local_state));
    // do something else.
} //由于scoped_thread的析构函数会等待线程结束,所以f执行结束后不会立刻退出

int main(int argc, const char** argv) {
    f();
    return 0;
}
复制代码

std::thread 对移动的支持同样考虑了 std::thread 对象的容器,如果那些容器是移动感知的,就可像下面的例子一样,生成一批线程,然后等待完成。

#include <iostream>
#include <thread>
#include <vector>
#include <algorithm>
#include <functional>
#include <string>
#include <unistd.h>

void do_work(unsigned int id)
{
    std::cout << "thread::" + std::to_string(id) + " start!++++++++++++++\n" << std::endl;
    sleep(3);
    std::cout << "thread::" + std::to_string(id) + " stop!---------------\n" << std::endl;
}

void f()
{
    std::vector<std::thread> threads;
    // 生成一批线程
    for (unsigned int i = 0; i < 20 ;i++)
    {
        threads.push_back(std::thread(do_work, i));
    }
    // 对每个线程调用join等待线程完成
    std::for_each(threads.begin(), threads.end(), std::mem_fn(&std::thread::join));
    /***************************************
    这一条语句作用同下这段代码
    std::vector<std::thread>::iterator it = threads.begin();
    for ( ;it != threads.end(); it++)
    {
        it->join();
    }
    ****************************************/
}
int main(int argc, const char** argv) {
    f();
    return 0;
}

复制代码

2.6 在运行时选择线程数量

C++库中对此有帮助的特性是 std::thread::hardware_concurrency()。这个函数是一个静态方法返回支持的并发线程数。若该信息不可用返回0。这个值仅仅是作为一个提示,为了避免运行比硬件所能支持的更多线程数(超额订阅),以为上下文切换将意味着更多的线程会降低性能。

#include <iostream>
#include <thread>
int main(int argc, const char** argv) {
    std::cout << "hardware_concurrency is " << std::thread::hardware_concurrency() << std::endl;
    return 0;
}
/*************************************
在我的虚拟机上输出:
hardware_concurrency is 4
*************************************/
复制代码

通过这个静态方法能够获取到系统的最大同时运行线程数量,我们可以根据这个值在程序中动态调整我们的线程数量以适配不同的运行环境。

2.7 线程标识

线程标识符是 std::thread::id 。类 thread::id 是轻量的可频繁复制类,它作为 std::thread 对象的唯一标识符工作。此类的实例亦可保有不表示任何线程的特殊辨别值。一旦线程结束,则 std::thread::id 的值可为另一线程复用。此类为用作包括有序和无序的关联容器的关键而设计。

获取方式有两种:

一、从与之相关联的 std::thread 对象中通过调用 get_id() 成员函数来获得。如果无关联的线程,则返回默认构造的 std::thread::id

二、当前线程的线程标识可以通过 std::this_thread::get() 来获取。

#include <iostream>
#include <thread>
#include <chrono>
 
void foo()
{
    std::this_thread::sleep_for(std::chrono::seconds(1));
    // 打印本线程id
    std::cout << std::hex << "id of thread foo() is " << std::this_thread::get_id() << std::endl;
}
 
int main(int argc, char** argv)
{
    std::thread t1(foo);
    std::thread::id t1_id = t1.get_id();
 
    std::thread t2;
    std::thread::id t2_id = t2.get_id();
 
    std::cout << std::hex << "t1's id: " << t1_id << '\n';
    std::cout << "t2's id: " << t2_id << '\n';
    // 判断id是否存在
    if(t2_id == std::thread::id())
    {
        std::cout << "message" << std::endl;
    }
    t1.join();
   
}
/*************************************
在我的虚拟机上输出:
t1's id: 7f726b0cc700
t2's id: thread::id of a non-executing thread
message
id of thread foo() is 7f726b0cc700
*************************************/
复制代码

线程库不限制用户检查线程的标识符是否相同,std::thread::id 类型的对象提供了一套完整的比较运算符,提供了不同值的总排序。这就允许它们在关系型容器中被用作主键,或排序。比较运算符为 std::thread::id 所有不相等的值提供了一个总排序。标准库还提供了 std::hash<std::thread::id> ,使得 std::thread::id 类型的值可在新的无序关系型容器中作为主键来用。

【2021/10/26】

猜你喜欢

转载自juejin.im/post/7031428773018861575