0%

C++并发编程(一):简介与线程

读者注意:本博客是关于C++11新标准下的并发和多线程编程(C++多线程有C++11、Boost线程库和POSIX多线程三大版本)

  • POSIX的线程相关更多的是函数形式,这也理所当然,因此底层是C
  • C++标准多线程和Boost线程是类形式,这点要注意。

1 概览

1.1 认识并发

如下图,在单核处理器中我们可看到AB两任务是以“这个任务做一会,再切换到别的任务,再做一会儿”的方式,让任务看起来是并行执行的。这种方式称为“任务切换(task switching)”。但是,实际上是并发形式的,因为任务切换得太快,以至于无法感觉到任务在何时会被暂时挂起 系统每次从一个任务切换到另一个时都需要切换一次上下文(context switch),任务切换也有时间开销。

  • 进行上下文的切换时,操作系统必须为当前运行的任务保存CPU的状态和指令指针,并计算出要切换到哪个任务,并为即将切换到的任务重新加载处理器状态。
  • 然后,CPU可能要将新任务的指令和数据的内存载入到缓存中,这会阻止CPU执行任何指令,从而造成的更多的延迟

1.2 并发途径

  • 多进程并发:应用程序分为多个独立的进程,它们在同一时刻运行,就像同时进行网页浏览和文字处理一样。
    • 独立的进程需要通过进程间常规的通信渠道传递讯息(信号、套接字、文件、管道等等)。
    • 运行多个进程所需的固定开销:需要时间启动进程,操作系统需要内部资源来管理进程,需要分配维护独立的一份进程内存。
    • 进程并发一般不需要注意并发带来的安全问题,因为各个进程独立拥有自己的内存空间;
  • 多线程并发:在单个进程中运行多个线程,线程各自允许自己的任务。
    • 进程中的所有线程都共享地址空间,并且所有线程能访问到大部分数据———全局变量仍然是全局的,指针、对象的引用或数据可以在线程之间传递。
    • 地址空间共享,因此使用多线程相关的开销远远小于使用多个进程。
    • 但是这种线程间内存共享也有代价,当数据要被多个线程访问,那么程序员必须确保每个线程所访问到的数据是一致的,即要保证线程安全。

1.3为何要使用并发

使用并发的两个主要原因就是使关注点分离(SOC)和提升性能

  • 分离关注点:通过将相关的代码与无关的代码分离,可以使程序更容易理解和测试,从而减少出错的可能性。使得代码解耦合。

  • 提升性能:随着计算机的发展,现在的计算机中央处理单元CPU一般都是多核的,充分利用这种硬件资源能够很好的提升程序性能。

不使用并发场景:当发现收益<成本时就不能使用。把新线程加入调度器、启动线程(线程的上下文切换)需要耗费CPU资源,当发现这些线程间的切换比处理时间还长,降低了效率,此时就不应该使用或者说不应该使用这么大熟练的线程。而且若你为每个线程分配一个1M的堆栈,只需要4098个线程就会用尽所有地址空间。

1.4 与 C++11 多线程相关的头文件

1.4.1 总览

以下是C++的一些并发编程相关的特性和工具:

  • 线程:C++11引入了线程库,允许开发人员创建和管理线程。通过线程库,开发人员可以创建、启动和停止线程,并在线程之间共享数据。

  • 互斥量:互斥量是一种用于保护共享资源免受并发访问的机制。C++的互斥量可以通过std::mutex类来创建和管理,开发人员可以使用它来实现对共享资源的互斥访问。

  • 条件变量:条件变量是一种线程间同步的机制,允许线程等待某个特定条件的出现。C++的条件变量可以通过std::condition_variable类来创建和管理,开发人员可以使用它来实现线程之间的通信。

  • 原子操作:原子操作是一种线程间同步的机制,可以确保操作的原子性和可见性。C++的原子操作可以通过std::atomic类来创建和管理,开发人员可以使用它来实现对共享资源的原子操作。

  • Futures和Promises:Futures和Promises是一种异步编程的机制,允许开发人员在一个线程中执行一个操作,并在另一个线程中等待操作完成。C++的Futures和Promises可以通过std::future和std::promise类来创建和管理。

  • 并行算法:C++标准库提供了一些并行算法,允许开发人员在多个线程上并行执行某些操作。其中一些算法包括std::for_each、std::accumulate、std::sort等。

在并发编程时,开发人员需要特别注意避免数据竞争、死锁等问题,以确保程序的正确性和可靠性。

1.4.2 头文件总览

首先熟悉一下 C++11 的多线程模块的头文件吧。C++11 新标准中引入了多个头文件来支持多线程编程,他们分别是 <atomic><thread><mutex><condition_variable><future>

  • <atomic>:该头文件用于原子操作,主要声明了两个类,std::atomicstd::atomic_flag,另外还声明了一套 C 风格的原子类型和与 C 兼容的原子操作的函数。
  • <thread>:该头文件用于线程操作,主要声明了 std::thread 类,另外 std::this_thread 命名空间也在该头文件中,包含一些线程的操作函数。
  • <mutex>:该头文件用于互斥量操作,主要声明了与互斥量相关的类,包括 std::mutex 系列类,std::lock_guardstd::unique_lock,以及其他的类型和函数。
  • <condition_variable>:该头文件用于条件变量操作,主要声明了与条件变量相关的类,包括 std::condition_variablestd::condition_variable_any
  • <future>:该头文件用于异步调用操作,主要声明了 std::promisestd::package_task 两个 Provider 类,以及 std::futurestd::shared_future 两个 Future 类,另外还有一些与之相关的类型和函数,std::async() 函数就声明在此头文件中。

2 线程管理

在本节主要讲述线程管理,涉及启动一个线程,等待这个线程结束,或放在后台运行;怎么给已经启动的线程函数传递参数,以及怎么将一个线程的所有权从当前std::thread对象移交给另一个。最后,再来确定线程数,以及识别特殊线程。线程头文件为<thread>

2.1 thread对象:启动线程

线程在std::thread对象创建(为线程指定任务)时启动。即使用C++线程库启动线程,可以归结为构造 std::thread 对像。因此对于创建一个线程对象来说,可以调用不同的构造函数,因此这里不会做详细的构造函数阐述,只举示例,其构造函数如下:

次序 构造函数
default (1) thread() noexcept;
initialization (2) template explicit thread (Fn&& fn, Args&&… args);
copy [deleted] (3) thread (const thread&) = delete;
move (4) thread (thread&& x) noexcept;
  • 默认构造函数,创建一个空的 thread 执行对象。
  • 初始化构造函数,创建一个 thread 对象,该 thread 对象可被 joinable,新产生的线程会调用 fn 函数,该函数的参数由 args 给出。
  • 拷贝构造函数(被禁用),意味着 thread 不可被拷贝构造。
  • move 构造函数,move 构造函数,调用成功之后 x 不代表任何 thread 执行对象。

下面这个例子创建了一个my_thread多线程对象,传入的为可调用对象进去作为线程的处理函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <thread>
#include <iostream>
class background_task
{
public:
void operator()() const
{
do_something();
do_something_else();
}
};
background_task f;
std::thread my_thread(f);

2.1.1 启动线程时易错点
  • 注意,当把函数对象传入到线程构造函数时,需要避免下述的“语法解析”:如果你传入的是一个零时变量,而不是一个命名变量,C++会将器解析为函数声明,而不是创建类型对象

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    std::thread my_thread(background_task());
    //background_task()使用默认构造创建了一个background_task零时对象
    //那么此时my_thread就会被解析为一个函数声明
    /*
    解决方法
    */
    std::thread my_thread((background_task())); //1
    std::thread my_thread{background_task()}; //2
    background_task f;
    std::thread my_thread(f); //3
    std::thread my_thread([]{
    do_something();
    do_something_else();
    }); //4,使用lambda

  • 启动了线程,在对象销毁之前必须明确是等待线程结束(join加入式)还是自主运行(detach分离式)。如果在std::thread对象销毁之前还没做出决定,整个程序会终止(std::thread的析构会调用std::terminate),因此,即便有异常存在,也必须确保线程能够在joindetach

  • 如果采用分离式而不是加入式,必须保证线程结束之前可访问数据的有效性。比如下面这种情况是很糟糕的:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    //清单2.1
    struct func
    {
    int& i;
    func(int& _i):i(_i){}
    void operator() ()
    {
    for(unsigned int j=0;j<100000;++j)
    {
    do_something_else(i);
    }
    }
    };
    void oops()
    {
    int some_local_state=0;
    func my_func(some_local_state);
    std::thread my_thread(my_func); //新线程对象,入口函数为func()
    my_thread.detach(); //2. 不等待新线程的结束,采用分离式
    } //3.调用线程到这里执行完毕退出
    • 上面我们可看到,我们在函数oops创建线程,线程使用可调用对象作为线程的函数入口,线程处在运行式就会调用do_something_else
    • 因为新线程采用分离式,把么旧线程在执行完oops函数后就会退出,那么my_func对象就会被销毁,内部的成员变量i也就被销毁了,然而新线程还在运行中,此时调用发现i被销毁,访问i是一个未定义的行为。
    • 解决方法:
      • 编写代码时规避这样的易出错的地方,即避免主线程退出时,子线程正在调用主线程的局部变量做参数进入处理函数。
      • 通过加入式的方式确保线程在函数退出前结束。

2.2 join:等待线程完成

如果需要等待线程,相关的 std::thread 实例需要使用join()回收线程。在这种情况下,因为原始线程会阻塞,那么在其生命周期中并没有做什么事,使得用一个独立的线程去执行函数变得收益甚微。因此但在实际编程中,原始线程要么有自己的工作要做;要么会启动多个子线程来做一些有用的工作,并等待这些线程结束

2.2.1 特殊情况下的等待

我们必须对一个还未销毁的 std::thread 对象使用join()detach()

如果想要分离一个线程,可以在线程启动后,直接使用detach()进行分离。如果打算等待对应线程,则需要细心挑选调用join()的位置

在上面我们提到,当采用join等待线程结束,但是调用前出现异常,join就会失败,必须处理这样的特殊情况:

  • 发生异常时,保证在异常处理中也能调用join(),一种方法时采用下述代码使用了 try/catch 块确保访问本地状态的线程退出后,函数才退出

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    struct func; // 定义在清单2.1中
    void f()
    {
    int some_local_state=0;
    func my_func(some_local_state);
    std::thread t(my_func);
    try
    {
    do_something_in_current_thread();
    }
    catch(...)
    {
    t.join(); // 1
    throw;
    }
    t.join(); // 2
    }

  • 另一种是使用RAII机制(资源获取即初始化),即提供一个类,在析构函数中使用join():

    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
    class thread_guard
    {
    std::thread& t;
    public:
    explicit thread_guard(std::thread& t_):
    t(t_)
    {}
    ~thread_guard()
    {
    if(t.joinable()) // 1
    {
    t.join(); // 2
    }
    }
    /*
    * 拷贝构造和赋值声明为删除,避免类自动合成默认的,这样避免使得回收的线程赋值产生错误
    */
    thread_guard(thread_guard const&)=delete; // 3
    thread_guard& operator=(thread_guard const&)=delete;
    };
    struct func; // 定义在清单2.1中
    void f()
    {
    int some_local_state=0;
    func my_func(some_local_state);
    std::thread t(my_func);
    thread_guard g(t);
    do_something_in_current_thread();
    } // 4

RAII机制:RAII是C++语法体系中的一种常用的合理管理资源避免出现内存泄漏的常用方法。以对象管理资源,利用的就是C++构造的对象最终会被对象的析构函数销毁的原则。RAII的做法是使用一个对象,在其构造时获取对应的资源,在对象生命期内控制对资源的访问,使之始终保持有效,最后在对象析构的时候,释放构造时获取的资源

优点 不需要显式地释放资源。 采用这种方式,对象所需的资源只在其生命期内始终保持有效。

2.3 detach:后台运行线程

  • 使用detach()会让线程在后台运行,这就意味着主线程不能与之产生直接交互.不会等待这个线程结束;如果线程分离,那么就不可能有 std::thread 对象能引用它,因为它已经于当前对象分离了,同样分离线程不能被join。因此通常称分离线程为守护线程(daemon threads)。

  • 不过C++运行库保证,当线程退出时,相关资源的能够正确回收,后台线程的归属和控制C++运行库都会处理

  • 一般进行检查当线程对象使用t.joinable()返回的是true,就可以使用t.detach()

    1
    2
    3
    4
    std::thread t(do_background_work);	//t是执行线程的对象,可分离
    assert(t.joinable()) //t.joinable()返回true,t未分离
    t.detach();
    assert(t.joinable()) //t.joinable()返回false,t已经分离

2.3.1 使用分离线程处理文档

试想如何能让一个文字处理应用同时编辑多个文档。无论是用户界面,还是在内部应用内部进行,都有很多的解决方法。虽然,这些窗口看起来是完全独立的,每个窗口都有自己独立的菜单选项,但他们却运行在同一个应用实例中。一种内部处理方式是,让每个文档处理窗口拥有自己的线程;每个线程运行同样的的代码,并隔离不同窗口处理的数据。如此这般,打开一个文档就要启动一个新线程。因为是对独立的文档进行操作,所以没有必要等待其他线程完成。因此,这里就可以让文档处理窗口运行在分离的线程上。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
void edit_document(std::string const& filename)
{
open_document_and_display_gui(filename);
while(!done_editing())
{
user_command cmd=get_user_input();
if(cmd.type==open_new_document)
{
std::string const new_name=get_filename_from_user();
std::thread t(edit_document,new_name); // 1
t.detach(); // 2
}
else
{
process_user_input(cmd);
}
}
}

上述代码中如果用户选择打开一个新文档,为需要启动一个新线程去打开新文档①,并分离线程②。与当前线程做出的操作一样,新线程只不过是打开另一个文件而已。所以,edit_document函数可以复用,通过传参的形式打开新的文件

2.4 向线程函数传递参数

线程函数传递参数是在std::thread 构造函数中传递的,即线程对象创建时。

  • 当线程创建时,std::thread的参数都会拷贝到线程内存栈中(标准规定)。这种情况,当主线程使用join()时,不会出现任何情况,;但是当主线程使用detach()分离子线程时,旧必须保证子线程在完成线程间上下切换前(拷贝和类型转换前)主线程还未退出,否则会发生错误
  • 按地址传递的指针,虽然是拷贝,但是指向同一个内存地址,因此对于指针参数,自然能够改变原值,必须保证主线程要在子线程结束后退出,否则会导致访问一个悬空指针出现错误

如下面代码就是上面提到的detach情况,因传入为const char*,若线程还未完成切换,主线程就退出了,那么在字面值转化成 std::string 对象之前崩溃(oops),从而导致线程的一些未定义行为

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void f(int i,std::string const& s);
void oops(int some_param)
{
char buffer[1024]; // 1
sprintf(buffer, "%i",some_param);
std::thread t(f,3,buffer); // 2
t.detach();
}
//修改
void f(int i,std::string const& s);
void not_oops(int some_param)
{
char buffer[1024];
sprintf(buffer,"%i",some_param);
std::thread t(f,3,std::string(buffer)); // 使用std::string,避免悬垂指针
t.detach();
}
  • 标准约定std::thread构造时向函数对象传递实际参数的拷贝(支持移动语义),而不是转发实际参数。即fun的形式参数是被a拷贝初始化的引用,而不是a的引用,所以形式参数a并不是main函数内a的引用,在fun内赋值自然不会改变main函数内a的值。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    void fun(int &a)
    {
    a =1 ;
    }
    int main()
    {
    int a =0 ;
    std::thread t(fun,a) ;
    t.join () ;
    return 0 ;
    }
    如果你希望像c++以往“以引用的方式传参”,常用解决方案之一是使用std::ref,即std::thread(fun, std::ref(a));。参数要拷贝到线程独立内存中是默认的,即使参数是引用的形式,也可以在新线程中进行访问。但必须保证a在子线程执行完前a未被销毁**
    1
    2
    //此时fun函数收到就是a的引用,而不是a拷贝初始化的引用,能够改变院长
    std::thread(fun, std::ref(a));

2.5.1 线程传递局部参数

如果要给各个线程传递局部参数,thread::local_data 是 C++20 新增的一个类模板,它提供了一种在多线程程序中为每个线程分配私有数据的方式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <iostream>
#include <thread>
#include <string>
#include <chrono>

std::thread_local int local_data = 0;

void thread_func(const std::string& name) {
local_data += 1;
std::cout << "Thread " << name << " local_data: " << local_data << "\n";
std::this_thread::sleep_for(std::chrono::seconds(1));
}

int main() {
std::thread t1(thread_func, "A");
std::thread t2(thread_func, "B");

t1.join();
t2.join();

return 0;
}

这个程序创建了两个线程,它们都调用了 thread_func 函数。在 thread_func 中,我们对 local_data 进行了自增并输出了当前线程的 local_data 值。由于 local_data 是使用 thread_local 修饰的,因此每个线程都会有一个独立的拷贝。

需要注意的是,thread_local 变量的初始化是延迟的。这意味着在首次访问变量之前,它不会被初始化。这个特性有时候会带来一些麻烦,需要特别注意。 此外,由于 thread_local 变量的生命周期与线程相同,因此需要注意避免出现悬垂指针的情况。

2.5 转移线程所有权move

转移线程所有权是指std::thread支持移动std::move.执行线程的所有权可以在 std::thread 实例中移动,下面将展示一个例子。例子中,创建了两个执行线程,并且在 std::thread 实例之间(t1,t2和t3)转移所有权:

1
2
3
4
5
6
7
8
void some_function();
void some_other_function();
std::thread t1(some_function); // 1
std::thread t2=std::move(t1); // 2
t1=std::thread(some_other_function); // 3
std::thread t3; // 4
t3=std::move(t2); // 5
t1=std::move(t3); // 6 赋值操作将使程序崩溃

详解:

  • 首先t1与执行some_function的启动线程相关联
  • 然后执行std::move(t1)后将t1的所有权给了t2t1此时没有任何关联启动线程
  • 接着t1接受零时启动线程std::thread(some_other_function)
  • 然后使用默认构造函数创建了一个t3线程对象,t3与任何执行线程都没有关联
  • 接着t2some_function的执行线程给了t3,截至目前以前正常,t1关联some_other_function执行线程,而t2未关联,t3关联刚刚拿到的some_function的执行线程
  • 第⑥步发送错误,因此t1已经关联了some_other_function,又想把some_function的执行线程给它,就会发送错误

2.6 查看线程数量和线程标识id

  • std::thread::hardware_concurrency()在新版C++标准库中是一个很有用的函数。这个函数将 返回能同时并发在一个程序中的线程数量
  • 线程标识类型是 std::thread::id ,可以通过两种方式进行检索。第一种,可以通过调用 std::thread对象的成员函数get_id() 来直接获取。如果 std::thread 对象没有与任何执行线程相关联, get_id() 将返回std::thread::type 默认构造值,这个值表示“没有线程”。

  • 第二种,当前线程中调用 std::this_thread::get_id() (这个函数定义在<thread>头文件中)也可以获得线程标识。

1
2
3
4
5
6
7
#include <thread>
std::thread::hardware_concurrency(); //1
std::this_thread::get_id() //`

int x=10;
std::thread t(func,x);
std::thread::id t_id=x.get_id(); //2

2.7 其他函数

除了 join,detach,joinable 之外,std::thread 头文件还在 std::this_thread 命名空间下提供了一些辅助函数:

  • get_id: 返回当前线程的 id
  • yield: 函数让出当前线程的执行权,让其他线程运行,即告知调度器运行其他线程,可用于当前处于繁忙的等待状态
  • sleep_for:给定时长,阻塞当前线程
  • sleep_until:阻塞当前线程至给定时间点

其中 yield 是一个特殊的“线程睡眠”函数:

  • std::this_thread::yield() 是将当前线程所抢到的 CPU ”时间片A”让渡给其他线程(其他线程会争抢”时间片A”,注意。此时”当前线程”不参与争抢。等到其他线程使用完”时间片A”后,再由操作系统调度,当前线程再和其他线程一起开始抢 CPU 时间片。

  • 如果将std::this_thread::yield() 上述语句修改为: return;,则将未使用完的 CPU ”时间片A”还给操作系统,再由操作系统调度,当前线程和其他线程一起开始抢CPU时间片。

因此 yield 使用的场景就是当当前线程运行条件不满足时调用,避免一个线程频繁与其他线程争抢 CPU 时间片, 从而导致多线程处理性能下降。sleep_for 也是让线程等待,需要等待若干时间