C++ 线程及线程池详解


一、前言

1. 参考链接

  1. https://en.cppreference.com/w/cpp/header/thread
  2. https://subingwen.cn/cpp/thread
  3. https://github.com/progschj/ThreadPool

2. 编译参数

"args": [
    "-g",
    "${file}",
    "-lpthread",
    "-std=c++11",
    "-fdiagnostics-color=always",
    "-o",
    "${fileDirname}/${fileBasenameNoExtension}"
]

二、std::thread

C++11 后增加了线程以及线程相关的类 —— std::thread。

大致结构如下:

namespace std {
    class thread {
    public:
        // types
        class id;
        using native_handle_type = /* implementation-defined */;

        // construct/copy/destroy
        thread() noexcept;
        template<class F, class... Args> explicit thread(F&& f, Args&&... args);
        ~thread();
        thread(const thread&) = delete;
        thread(thread&&) noexcept;
        thread& operator=(const thread&) = delete;
        thread& operator=(thread&&) noexcept;

        // members
        void swap(thread&) noexcept;
        bool joinable() const noexcept;
        void join();
        void detach();
        id get_id() const noexcept;
        native_handle_type native_handle();

        // static members
        static unsigned int hardware_concurrency() noexcept;
    };
}

class thread

  • 表示单个执行线程。
  • 通过线程可以使多个函数同时执行。
  • 线程在构造关联的线程对象时立即开始执行。
  • 顶层函数的返回值将被忽略,若它引发异常终止,调用 std::terminate。
  • 顶层函数可以通过 std::promise 或通过修改共享变量,将其返回值或异常传递给调用方。
  • std::thread 对象也可能处于不表示任何线程的状态(默认构造、被移动、 detach 或 join 后),并且执行线程可能与任何 thread 对象无关(detach 后)。
  • 一个线程只能有一个对象表示,不能由两个对象表示;std::thread 不可复制,但是可移动构造且可移动赋值。

1. 构造函数

// 构造函数①
thread() noexcept;
// 构造函数②
thread( thread&& other ) noexcept;
// 构造函数③
template< class Function, class... Args >
explicit thread( Function&& f, Args&&... args );
// 禁止拷贝构造
thread( const thread& ) = delete;
  • 构造函数①:默认构造函,构造一个线程对象,线程中不执行任何处理动作。
  • 构造函数②:移动构造函数,将 other 的线程所有权转移给新的 thread 对象。之后 other 不再表示执行线程。
  • 构造函数③:创建线程对象,并在该线程中执行函数 f 中的业务逻辑,args 是要传递给函数 f 的参数。
    • 任务函数 f 的可选类型有很多,具体如下:
      • 普通函数,类成员函数,匿名函数,仿函数(这些都是可调用对象类型)。
      • 可以是可调用对象包装器类型,也可以是使用绑定器绑定之后得到的类型(仿函数)。
  • 禁止拷贝构造:=delete 显示删除拷贝构造,不允许线程对象之间的拷贝。

2. 公共成员函数

2.1 get_id()

应用程序启动之后默认只有一个线程(主线程),通过主线程类创建出的线程称为子线程,每个被创建出的线程实例都对应一个线程 ID(即 ID 是唯一)。

get_id()可以获取线程的 ID。

函数原型

std::thread::id get_id() const noexcept;

示例代码

void func(int num, string str) {
    cout << "这里是子线程!" << 
        " 我的参数有: num-" << num << ", str-" << str << endl;
}

int main() {
    thread t(func, 123, "aaa");

    cout << "子线程 t 的线程 ID: " << t.get_id() << endl;
    cout << "主线程的线程 ID: " << this_thread::get_id() << endl;

    return 0;
}
  • thread t(func, 123, "aaa");:创建了子线程对象 t,这个子线程会运行 func() 函数。
    • func() 是一个回调函数,线程启动之后就会执行这个任务函数。
    • func() 的参数是通过 thread 的参数进行传递的,123, “aaa” 都是调用 func() 需要的实参。
    • 线程类的构造函数③ 是一个变参函数。
    • 任务函数 func() 一般返回值指定为 void,因为子线程在调用这个函数的时候不会处理其返回值。
  • 通过线程对象调用 get_id()就可以获得线程的线程 ID。

注:在上面的示例中存在一个问题,在主线程中创建出一个子线程,打印子线程的线程 ID,主线程执行完毕就退出了。默认情况下,主线程销毁时会将与其关联的子线程也一并销毁,但是这时有可能子线程中的任务还没有执行完毕,最后也就得不到我们想要的结果了。

当启动了一个线程(创建了一个 thread 对象)之后,在这个线程结束的时候(std::terminate ())我们有两种方法来回收线程所用的资源:

2.2 join()

join()字面意思是连接一个线程,这代表着 join()会主动地等待线程的终止(线程阻塞)。

在某个线程中通过子线程对象调用 join()函数,调用 join()函数的线程被阻塞(函数在哪个线程中被执行,函数就阻塞哪个线程),但是子线程对象中的任务函数会继续执行,当任务执行完毕之后 join()会清理当前子线程中的相关资源然后返回,同时,调用 join()函数的线程解除阻塞继续向下执行。

函数原型

void join();

示例代码

int main() {
    thread t(func, 123, "aaa");
    cout << "线程 t1 的线程 ID: " << t.get_id() << endl;
    t.join();

    cout << "主线程的线程 ID: " << this_thread::get_id() << endl;

    return 0;
}

当主线程运行到 t.join();,根据子线程对象 t 的任务函数 func()的执行情况,主线程会做如下处理:

  • 如果任务函数 func()还没执行完毕,主线程阻塞,直到任务执行完毕,主线程解除阻塞,继续向下运行。
  • 如果任务函数 func()已经执行完毕,主线程不会阻塞,继续向下运行。

2.3 detach()

detach()函数的作用是进行线程分离,分离主线程和创建出的子线程。在线程分离之后,主线程退出也会一并销毁创建出的所有子线程,在主线程退出之前,它可以脱离主线程继续独立的运行,任务执行完毕之后,这个子线程会自动释放自己占用的系统资源。

函数原型

void detach();

线程分离函数没有参数也没有返回值,只需要在线程成功之后,通过线程对象调用该函数即可,继续将上面的测试程序修改一下:

#include <chrono>

int main() {
    thread t(func, 123, "aaa");
    cout << "线程 t1 的线程 ID: " << t.get_id() << endl;
    t.detach();
    // 让主线程休眠, 等待子线程执行完毕
    this_thread::sleep_for(chrono::seconds(5));

    cout << "主线程的线程 ID: " << this_thread::get_id() << endl;

    return 0;
}

线程分离函数 detach ()不会阻塞线程,但是子线程和主线程分离之后,在主线程中就不能再对这个子线程做任何控制了。例如:通过 join ()阻塞主线程等待子线程中的任务执行完毕,或者调用 get_id ()获取子线程的线程 ID。

2.4 joinable()

joinable()函数用于判断主线程和子线程是否处理关联(连接)状态.。该函数返回一个布尔类型:

  • 返回值为 true:主线程和子线程之间有关联(连接)关系。
  • 返回值为 false:主线程和子线程之间没有关联(连接)关系。

函数原型

bool joinable() const noexcept;

示例代码

void foo() {
    this_thread::sleep_for(std::chrono::seconds(1));
}

int main() {
    thread t1;
    cout << "before starting, joinable: " << t1.joinable() << endl;

    t1 = thread(foo);
    cout << "after starting, joinable: " << t1.joinable() << endl;

    t1.join();
    cout << "after joining, joinable: " << t1.joinable() << endl;

    thread t2(foo);
    cout << "after starting, joinable: " << t2.joinable() << endl;
    
    t2.detach();
    cout << "after detaching, joinable: " << t2.joinable() << endl;
}

运行结果

before starting, joinable: 0
after starting, joinable: 1
after joining, joinable: 0
after starting, joinable: 1
after detaching, joinable: 0

基于示例代码打印的结果可以得到以下结论:

  • 在创建的子线程对象的时候,如果没有指定任务函数,那么子线程不会启动,主线程和这个子线程也不会进行连接。
  • 在创建的子线程对象的时候,如果指定了任务函数,子线程启动并执行任务,主线程和这个子线程自动连接。
  • 子线程调用了 detach()函数之后,父子线程分离,同时二者的连接断开,调用 joinable()返回 false。
  • 在子线程调用了 join()函数,子线程中的任务函数继续执行,直到任务处理完毕,这时 join()会清理(回收)当前子线程的相关资源,所以这个子线程和主线程的连接也就断开了,因此,调用 join()之后再调用 joinable()会返回false。

2.5 operator=

线程中的资源是不能被复制的,因此通过 = 操作符进行赋值操作最终并不会得到两个完全相同的对象。

函数原型

thread& operator=( thread&& other ) noexcept;

函数参数

  • other:赋值给此 thread 对象的另一 thread 对象。
// move (1)	
thread& operator= (thread&& other) noexcept;
// copy [deleted] (2)	
thread& operator= (const other&) = delete;

通过以上 = 操作符的重载声明可以得知:

  • 如果 other 是一个右值,会进行资源所有权的转移。
  • 如果 other 不是右值,禁止拷贝,拷贝构造函数函数被显示删除(=delete)。

左值、右值

2.6 native_handle()

native_handle()返回实现定义的基础线程句柄。

函数原型

native_handle_type native_handle();

返回实现定义的表示线程的句柄类型。

2.7 swap()

swap()用于交换两个现线程对象的基础句柄。

函数原型

void swap(std::thread& other ) noexcept;

函数参数:

  • orther:要交换的线程

示例代码

void func() {
    this_thread::sleep_for(chrono::seconds(1));
}

 
int main() {
    thread t1(func);
    thread t2(func);
 
    cout << "initial:" << '\n'
            << "thread 1 id: " << t1.get_id() << '\n'
            << "thread 2 id: " << t2.get_id() << '\n' << endl;
 
    swap(t1, t2);
 
    cout << "after swap(t1, t2):" << '\n'
            << "thread 1 id: " << t1.get_id() << '\n'
            << "thread 2 id: " << t2.get_id() << '\n' << endl;
 
    t1.join();
    t2.join();

    return 0;
}

运行结果:

initial:
thread 1 id: 140737348167232
thread 2 id: 140737339774528

after swap(t1, t2):
thread 1 id: 140737339774528
thread 2 id: 140737348167232

由此可以发现 swap()将两个线程交换了。

2.8 静态函数 —— hardware_concurrency()

thread 线程类还提供了一个静态方法,用于获取当前计算机的 CPU 核心数,根据这个结果在程序中创建出数量相等的线程,每个线程独自占有一个CPU核心,这些线程就不用分时复用 CPU 时间片,此时程序的并发效率是最高的。基本上可以视为处理器的核心数目。

函数原型

static unsigned int hardware_concurrency() noexcept;

示例代码

#include <iostream>
#include <thread>
using namespace std;

int main() {
    int num = thread::hardware_concurrency();
    cout << "CPU number: " << num << endl;
}

3. 非成员函数 std::swap()

重载的 std::swap 算法,交换两个线程 lhs.swap(rhs)

函数原型

void swap(std::thread &lhs, std::thread &rhs) noexcept;

函数参数:

  • lhs, rhs: 需要交换的线程

效果类似 thread 成员函数 swap()


三、std::this_thread 命名空间

命名空间 std::this_thread 提供了四个公共的成员函数,通过这些成员函数就可以对当前线程进行相关的操作了。

1. get_id()

get_id()方法可以获取当前线程的 ID。

函数原型

thread::id get_id() noexcept;

示例代码

void func() {
    cout << "子线程: " << this_thread::get_id() << endl;
}

int main() {
    cout << "主线程: " << this_thread::get_id() << endl;
    thread t(func);
    t.join();
}

程序启动时执行 main() 函数,此时只有一个线程也就是主线程。当创建子线程对象 t 之后,指定的函数 func() 会在子线程中执行,这时通过调用 this_thread::get_id() 可以得到当前子线程的线程 ID。


2. sleep_for()

thread 被创建后有五个状态,分别是:创建态、就绪态、阻塞态(挂起态)、退出态(终止态)。

为了能够实现并发处理,多个线程都是分时复用CPU时间片,快速的交替处理各个线程中的任务。因此多个线程之间需要争抢CPU时间片,抢到了就执行,抢不到则无法执行。

命名空间 this_thread提供的休眠函数 sleep_for()可以使线程从 运行态转变为 阻塞态并持续一段时长。阻塞态的线程会让出 CPU 资源,此时代码也不会执行。

函数原型:

template< class Rep, class Period >
void sleep_for( const std::chrono::duration<Rep, Period>& sleep_duration );

函数参数

  • sleep_duration: 表示休眠时长(指线程阻塞一定的时间长度),此函数可能因为调度或资源争议延迟,阻塞长于时间 sleep_duration。

示例代码

int main() {
    // 获取当前系统时间点
    auto start = chrono::system_clock::now();
    // 休眠时间为 2s
    chrono::milliseconds ms(2000);
    // 休眠两秒
    this_thread::sleep_for(ms);
    cout << "线程 ID: " << this_thread::get_id() << endl;
    // 获取当前系统时间点
    auto end = chrono::system_clock::now();
    chrono::duration<double, milli> elapsed = end - start;
    cout << "耗时:" << elapsed.count() << " ms\n";

    return 0;
}

运行结果

线程 ID: 140737348182976
耗时:2000.63 ms

3. sleep_until()

函数原型:

template< class Clock, class Duration >
void sleep_until( const std::chrono::time_point<Clock,Duration>& sleep_time );

函数参数

  • sleep_time: 表示要阻塞的时间(指定线程阻塞到某一个时间点),此函数可能由于调度或资源纠纷延迟,阻塞长于时间 sleep_time。

示例代码

int main() {
    // 获取当前系统时间点
    auto start = chrono::system_clock::now();
    // 时间间隔为2s
    chrono::milliseconds ms(2000);
    // 当前时间点之后休眠两秒
    this_thread::sleep_until(start + ms);
    cout << "线程 ID: " << this_thread::get_id() << endl;
    // 获取当前系统时间点
    auto end = chrono::system_clock::now();
    chrono::duration<double, milli> elapsed = end - start;
    cout << "耗时:" << elapsed.count() << " ms\n";

    return 0;
}

运行结果

线程 ID: 140737348182976
耗时:2001.14 ms

sleep_until()sleep_for()函数的功能是一样的,只不过前者是基于时间点去阻塞线程,后者是基于时间段去阻塞线程。


4. yield()

处于运行态的线程调用 yield()函数后,会主动让出自己已经抢到的 CPU 时间片,从运行态转变为就绪态。但是线程进入就绪态后会马上参与到下一轮 CPU 的抢夺战中,不排除它能继续抢到 CPU 时间片的情况。

函数原型:

void yield() noexcept;

示例代码

void func1() {
    auto start = chrono::system_clock::now();

    for (int i = 0; i < 10000; ++i) {
        this_thread::yield();
    }
    auto end = chrono::system_clock::now();
    chrono::duration<double, milli> elapsed = end - start;
    cout << "子线程1 ID: " << this_thread::get_id()  << "耗时:" << elapsed.count() << " ms\n";

}

void func2() {
    auto start = chrono::system_clock::now();

    for (int i = 0; i < 10000; ++i) {
        ;
    }
    auto end = chrono::system_clock::now();
    chrono::duration<double, milli> elapsed = end - start;
    cout << "子线程2 ID: " << this_thread::get_id()  << "耗时:" << elapsed.count() << " ms\n";

}


int main() {
    thread t1(func1);
    thread t2(func2);
    t1.join();
    t2.join();

    return 0;
}

运行结果

子线程2 ID: 140737339774528耗时:0.0288 ms
子线程1 ID: 140737348167232耗时:480.775 ms

子线程1 每一次执行 for 循环都会主动放弃一次 CPU 时间片,子线程2 并不会,所以可以发现两个线程的执行时间相差非常大。

结论

  • std::this_thread::yield()的目的是避免一个线程长时间占用 CPU 资源,从而导致多线程处理性能下降。
  • std::this_thread::yield()是让当前线程主动放弃了当前自己抢到的 CPU 资源,但是在下一轮还会继续抢。

四、call_once()

在某些特定情况下,某些函数只能在多线程环境下调用一次,例如:要初始化某个对象,而这个对象只能被初始化一次,此时可以使用 std::call_once()来保证函数在多线程环境下只能被调用一次(即使多个线程调用)。

使用 call_once()的时候,需要一个 once_flag作为 call_once()的传入参数。

函数原型

// 定义于头文件 <mutex>
template< class Callable, class... Args >
void call_once( std::once_flag& flag, Callable&& f, Args&&... args );

函数参数

  • flag:once_flag 类型的对象,要保证这个对象能够被多个线程同时访问到。
  • f:要调用的可调用对象,可以传递一个有名函数地址,也可以指定一个匿名函数。
  • args:传递给函数的参数。

Tips:

  • 若在调用 call_once 的时刻, flag 指示已经调用了 f ,则 call_once 立即返回(称这种对 call_once 的调用为消极)。
  • 否则,call_once 调用 f,参数为 args。
    • 若该调用抛异常,则传播异常给 call_once 的调用方,并且不翻转 flag 。
    • 若该调用正常返回,则翻转 flag ,并保证以同一 flag 对 call_once 的其他调用为消极。

示例代码1

// 要保证这个对象能够被多个线程同时访问到
std::once_flag flag;
 
void do_once() {
    std::call_once(flag, []() { 
        std::cout << "Simple example: called once." << endl;
        });
}
 
int main() {
    std::thread t1(do_once);
    std::thread t2(do_once);
    t1.join();
    t2.join();

    return 0;
}

运行结果1

Simple example: called once.

虽然运行的两个线程都执行了任务函数 do_once()但是 call_once()中指定的匿名函数只被执行了一次。

示例代码2

// 要保证这个对象能够被多个线程同时访问到
std::once_flag flag;
 
oid may_throw_function(bool do_throw) {
  if (do_throw) {
    std::cout << "throw: call_once will retry.";  // 这会出现多于一次
    throw std::exception();
  }
  std::cout << "Didn't throw, call_once will not attempt again." << endl;  // 保证一次
}
 
void do_once(bool do_throw) {
  try {
    std::call_once(flag, may_throw_function, do_throw);
  }
  catch (...) {
    cout << "  Catch exception!" << endl;
  }
}
 
int main() {
    std::thread t1(do_once, true);
    std::thread t2(do_once, false);
    std::thread t3(do_once, true);
    t1.join();
    t2.join();
    t3.join();

    return 0;
}

运行结果1:

throw: call_once will retry.  Catch exception!
Didn't throw, call_once will not attempt again.

三个个线程都执行了任务函数 do_once(),第一个线程抛出了异常导致 flag没有被翻转,第二个线程正常执行并翻转了 flag,第三个线程没有执行任务函数 do_once()


五、线程同步

1. 线程同步

假设有 4 个线程 A、B、C、D,当前一个线程 A 对内存中的共享资源进行访问的时候,其他线程 B, C, D 都不可以对这块内存进行操作,直到线程 A 对这块内存访问完毕为止,B,C,D 中的一个才能访问这块内存,剩余的两个需要继续阻塞等待,以此类推,直至所有的线程都对这块内存操作完毕。

线程对内存的这种访问方式就称之为线程同步,实际上所谓的同步并不是多个线程同时对内存进行访问,而是按照先后顺序依次进行的

1.1 为什么需要线程同步

如果多个线程需要对同一块内存进行操作(同时读同时写同时读写),尤其是对于后两种情况来说,如果没有任何的人为干涉就会出现各种各样的错误数据。

这是因为线程在运行的时候需要先得到 CPU 时间片,时间片用完之后需要放弃已获得的 CPU 资源,就这样线程频繁地在就绪态和运行态之间切换,更复杂一点还可以在就绪态、运行态、挂起态之间切换,这样就会导致线程的执行顺序并不是有序的,而是随机的混乱的。

示例代码

nt MAX = 15;
int num = 0;

void funcA() {
    for (int i = 0; i < MAX; i++) {
        int cur = num;
        cur++;
        this_thread::sleep_for(chrono::microseconds(5));
        num = cur;
        cout << "Thread A, id = " << this_thread::get_id() << ", num = " << num << endl;

    }
}

void funcB() {
    for (int i = 0; i < MAX; i++) {
        int cur = num;
        cur++;
        this_thread::sleep_for(chrono::microseconds(5));
        num = cur;
        cout << "Thread B, id = " << this_thread::get_id() << ", num = " << num << endl;

    }
}

int main() {
    thread t1(funcA);
    thread t2(funcB);

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

    return 0;
}

这个例子的意思原本是让两个线程交替数数,理想状态应该是每个线程数 15 次,num 最后等于 30,但是由于没有进行线程同步,导致结果可能不是理想结果。

运行结果

Thread A, id = 140737348167232, num = 1
Thread A, id = 140737348167232, num = 2
Thread A, id = 140737348167232, num = 3
Thread A, id = 140737348167232, num = 4
Thread A, id = 140737348167232, num = 5
Thread B, id = 140737339774528, num = 5
Thread A, id = 140737348167232, num = 6
Thread B, id = 140737339774528, num = 6
Thread A, id = 140737348167232, num = 7
Thread B, id = 140737339774528, num = 7
Thread A, id = 140737348167232, num = 8
Thread B, id = 140737339774528, num = 8
Thread A, id = 140737348167232, num = 9
Thread A, id = 140737348167232, num = 10
Thread B, id = 140737339774528, num = 9
Thread B, id = 140737339774528, num = 10
Thread A, id = 140737348167232, num = 11
Thread B, id = 140737339774528, num = 11
Thread A, id = 140737348167232, num = 12
Thread A, id = 140737348167232, num = 13
Thread B, id = 140737339774528, num = 12
Thread B, id = 140737339774528, num = 13
Thread A, id = 140737348167232, num = 14
Thread B, id = 140737339774528, num = 14
Thread A, id = 140737348167232, num = 15
Thread B, id = 140737339774528, num = 15
Thread B, id = 140737339774528, num = 16
Thread B, id = 140737339774528, num = 17
Thread B, id = 140737339774528, num = 18
Thread B, id = 140737339774528, num = 19

虽然每个线程内部都循环了 15 次,且每次数一个数,但是最终没有数到 30,通过输出的结果可以看到,有些数字被重复数了多次,其原因就是没有对线程进行同步处理,造成了数据的混乱。

在测试程序中两个线程共用全局变量 number 当线程变成运行态之后开始数数,从物理内存加载数据,让后将数据放到 CPU 进行运算,最后将结果更新到物理内存中。如果数数的两个线程都可以顺利完成这个流程,那么得到的结果肯定是正确的。

如果线程 A 执行这个过程期间就失去了 CPU 时间片,线程 A 被挂起了最新的数据没能更新到物理内存。线程 B 变成运行态之后从物理内存读数据,很显然它没有拿到最新数据,只能基于旧的数据往后数,然后失去 CPU 时间片挂起。线程 A 得到 CPU 时间片变成运行态,第一件事儿就是将上次没更新到内存的数据更新到内存,但是这样会导致线程 B 已经更新到内存的数据被覆盖,最终导致有些数据会被重复数很多次。

线程同步的目的是让多线程按照顺序依次执行临界区代码,这样做线程对共享资源的访问就从并行访问变为了线性访问,虽然访问效率降低了,但是保证了数据的正确性。

1.2 同步方式

为了防止多个线程访问共享资源出现数据混乱问题,常用线程同步来解决。常用线程同步的方式有:互斥锁、读写锁、条件变量、信号量。

共享资源就是多个线程共同访问的变量,这些变量通常为全局数据区变量或者堆区变量,这些变量对应的共享资源也被称之为临界资源。

找到临界资源之后,再找和临界资源相关的上下文代码,这样就得到了一个代码块,这个代码块可以称之为临界区。确定好临界区(临界区越小越好)之后,就可以进行线程同步了,线程同步的大致处理思路是这样的:

  • 在临界区代码的上边,添加加锁函数,对临界区加锁。
    -哪个线程调用这句代码,就会把这把锁锁上,其他线程就只能阻塞在锁上了。
  • 在临界区代码的下边,添加解锁函数,对临界区解锁。
    • 出临界区的线程会将锁定的那把锁打开,其他抢到锁的线程就可以进入到临界区了。
  • 通过锁机制能保证临界区代码最多只能同时有一个线程访问,这样并行访问就变为串行访问了。

2. 互斥锁

C++11 提供了四种互斥锁:

  • std::mutex:独占的互斥锁,不能递归使用。
  • std::timed_mutex:带超时的独占互斥锁,不能递归使用。
  • std::recursive_mutex:递归互斥锁,不带超时功能。
  • std::recursive_timed_mutex:带超时的递归互斥锁。

2.1 std::mutex

mutex 类是能用于保护共享数据免受从多个线程同时访问的同步原语。

Tips

  • 调用方线程从它成功调用 lock 或 try_lock 开始,到它调用 unlock 为止占有 mutex。
  • 线程占有 mutex 时,所有其他线程若试图要求 mutex 的所有权,则将阻塞(lock)或收到 false 返回值(try_lock)。
  • 调用方线程在调用 lock 或 try_lock 前必须不占有 mutex。
  • std::mutex 既不可复制也不可移动。
2.1.1 lock()

lock()用于给临界区加锁,如果互斥锁是打开的,调用 lock() 函数的线程会得到互斥锁的所有权,并将其上锁,其它线程再调用该函数的时候由于得不到互斥锁的所有权,就会被 lock() 函数阻塞。

当拥有互斥锁所有权的线程将互斥锁解锁,此时被 lock() 阻塞的线程解除阻塞,抢到互斥锁所有权的线程加锁并继续运行,没抢到互斥锁所有权的线程继续阻塞,直到获得锁。

函数原型

void lock();
2.1.2 try_lock()

try_lock()会尝试获得锁,但是会立即返回(不论是否获得锁),并不会阻塞线程。如果互斥锁是未锁定状态,得到了互斥锁所有权并加锁成功,函数返回 true,否则返回 false。

函数原型

bool try_lock();
2.1.3 unlock()

unlock()用于解锁互斥锁。

函数原型

void unlock();

当线程对互斥锁对象加锁,并且执行完临界区代码之后,一定要使用这个线程对互斥锁解锁,否则最终会造成线程的死锁。死锁之后当前应用程序中的所有线程都会被阻塞,并且阻塞无法解除,应用程序也无法继续运行。

2.1.4 示例

示例代码

int g_num = 0;  // 为 g_num_mutex 所保护
std::mutex g_num_mutex;
 
void slow_increment(int id) {
    for (int i = 0; i < 3; ++i) {
        g_num_mutex.lock();
        ++g_num;
        std::cout << id << " => " << g_num << '\n';
        g_num_mutex.unlock();
 
        std::this_thread::sleep_for(std::chrono::seconds(1));
    }
}
 
int main() {
    std::thread t1(slow_increment, 0);
    std::thread t2(slow_increment, 1);
    t1.join();
    t2.join();

    return 0;
}

运行结果

0 => 1
1 => 2
0 => 3
1 => 4
0 => 5
1 => 6

两个子线程执行的任务的一样的(其实也可以不一样,不同的任务中也可以对共享资源进行读写操作),在任务函数中把与全局变量相关的代码加了锁,两个线程只能顺序访问这部分代码(如果不进行线程同步打印出的数据是混乱且无序的)。另外需要强调一点:

  • 在所有线程的任务函数执行完毕之前,互斥锁对象是不能被析构的,一定要在程序中保证这个对象的可用性。
  • 互斥锁的个数和共享资源的个数相等,也就是说每一个共享资源都应该对应一个互斥锁对象。互斥锁对象的个数和线程的个数没有关系。

2.2 std::lock_guard

std::lock_guard是个模板类,可以简化互斥锁 lock()unlock()的写法,同时更加安全。

模板类定义和构造函数

// 类的定义,定义于头文件 <mutex>
template< class Mutex >
class lock_guard;

// 常用构造函数
explicit lock_guard( mutex_type& m );

lock_guard 构造对象时,会自动锁定互斥量,而在退出作用域后进行析构时就会自动解锁,从而避免忘记 unlock() 操作而导致线程死锁。

lock_guard 使用了 RAII(RAII是导致垃圾回收机制没有在C++中正式使用的原因之一) 技术,就是在类构造函数中分配资源,在析构函数中释放资源,保证资源出了作用域就释放。

示例代码

void slow_increment(int id) {
    for (int i = 0; i < 3; ++i) {
        lock_guard<mutex> lock(g_num_mutex);
        ++g_num;
        std::cout << id << " => " << g_num << '\n';
        std::this_thread::sleep_for(std::chrono::seconds(1));
    }
}

通过 lock_guard 代码被精简了,但是这种方式也有弊端,在上面的示例程序中整个 for 循环体都被当做了临界区,多个线程是线性的执行临界区代码的,因此临界区越大程序效率越低。


2.3 std::recursive_mutex

递归互斥锁 std::recursive_mutex允许同一线程多次获得互斥锁的所有权,可以用来解决同一线程需要多次获取互斥量时死锁的问题。

模板类定义

// 类的定义,定义于头文件 <mutex>
class recursive_mutex;

recursive_mutex 的使用场景之一是保护类中的共享状态,而类的成员函数可能相互调用
示例代码

class X {
    std::recursive_mutex m;
    std::string shared;
  public:
    void fun1() {
      std::lock_guard<std::recursive_mutex> lk(m);
      shared = "fun1";
      std::cout << "in fun1, shared variable is now " << shared << '\n';
    }
    void fun2() {
      std::lock_guard<std::recursive_mutex> lk(m);
      shared = "fun2";
      std::cout << "in fun2, shared variable is now " << shared << '\n';
      fun1();  // 递归锁在此处变得有用
      std::cout << "back in fun2, shared variable is " << shared << '\n';
    };
};
 
int main() {
    X x;
    std::thread t2(&X::fun2, &x);
    std::thread t1(&X::fun1, &x);
    t1.join();
    t2.join();

    return 0;
}

运行结果

in fun2, shared variable is now fun2
in fun1, shared variable is now fun1
back in fun2, shared variable is fun1
in fun1, shared variable is now fun1

运行结果可以发现 t2 在第一次获得互斥锁后,紧接着又获得了一次互斥锁权限,直到 t2 结束,t1 才获得互斥锁。

虽然递归互斥锁可以解决同一个互斥锁频繁获取互斥锁资源的问题,但是还是建议少用,主要原因如下:

  • 使用递归互斥锁的场景往往都是可以简化的,使用递归互斥锁很容易放纵复杂逻辑的产生,从而导致bug的产生
  • 递归互斥锁比非递归互斥锁效率要低一些。
  • 递归互斥锁虽然允许同一个线程多次获得同一个互斥锁的所有权,但最大次数并未具体说明,一旦超过一定的次数,就会抛出 std::system 错误。

2.4 std::timed_mutex

std::timed_mutex超时独占互斥锁,主要是在获取互斥锁资源时增加了超时等待功能,因为不知道获取锁资源需要等待多长时间,为了保证不一直等待下去,设置了一个超时时长,超时后线程就可以解除阻塞。

timed_mutex 提供通过 try_lock_for()try_lock_until()方法带时限地要求获得 timed_mutex 所有权。

模板类定义及其成员函数

// 类的定义,定义于头文件 <mutex>
class timed_mutex;

// 公共成员函数
void lock();
bool try_lock();
void unlock();

// std::timed_mutex 比 std::_mutex 多的两个成员函数
template <class Rep, class Period>
  bool try_lock_for (const chrono::duration<Rep,Period>& rel_time);

template <class Clock, class Duration>
  bool try_lock_until (const chrono::time_point<Clock,Duration>& abs_time);
  • try_lock_for:当线程获取不到互斥锁资源的时候,让线程阻塞一定的时间长度。
  • try_lock_until:当线程获取不到互斥锁资源的时候,让线程阻塞到某一个指定的时间点。
  • 返回值:当得到互斥锁的所有权之后,函数会马上解除阻塞,返回 true,如果阻塞的时长用完或者到达指定的时间点之后,函数也会解除阻塞,返回 false。

示例代码

timed_mutex g_mutex;

void work() {
    while (true) {
        // 通过阻塞一定的时长来争取得到互斥锁所有权
        if (g_mutex.try_lock_for(chrono::microseconds(500))) {
            cout << "当前线程ID: " << this_thread::get_id() 
                << ", 得到互斥锁所有权..." << endl;
            // 模拟处理任务用了一定的时长
            this_thread::sleep_for(chrono::seconds(1));
            // 互斥锁解锁
            g_mutex.unlock();
            break;
        }
        else {
            cout << "当前线程ID: " << this_thread::get_id() 
                << ", 没有得到互斥锁所有权..." << endl;
            // 模拟处理其他任务用了一定的时长
            this_thread::sleep_for(chrono::milliseconds(300));
        }
    }
}
 
int main() {
    thread t1(work);
    thread t2(work);

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

    return 0;
}

运行结果

当前线程ID: 140737348167232, 得到互斥锁所有权...
当前线程ID: 140737339774528, 没有得到互斥锁所有权...
当前线程ID: 140737339774528, 没有得到互斥锁所有权...
当前线程ID: 140737339774528, 没有得到互斥锁所有权...
当前线程ID: 140737339774528, 没有得到互斥锁所有权...
当前线程ID: 140737339774528, 得到互斥锁所有权...

在上面的例子中,通过一个 while 循环不停的去获取超时互斥锁的所有权,如果阻塞一段时间还是无法获得互斥锁所有权的话,就去处理别的任务,然后再次继续尝试,直到获得互斥锁的所有权,跳出循环体。


3. 条件变量

条件变量是 C++11 提供的另外一种用于等待的同步机制,它能阻塞一个或多个线程,直到另一线程修改共享变量(条件)发出通知或者超时时,才会唤醒当前阻塞的线程。

条件变量的主要作用并不是处理线程同步,而是进行线程阻塞,需要和互斥量配合起来使用(此时才能用于同步机制)。虽然条件变量和互斥锁都能阻塞线程,但是二者的效果是不一样的,二者的区别如下:

  • 假设有 A-Z 26 个线程,这 26 个线程共同访问同一把互斥锁,如果线程 A 加锁成功,那么其余 B-Z 线程访问互斥锁都阻塞,所有的线程只能顺序访问临界区。
  • 条件变量只有在满足指定条件下才会阻塞线程,如果条件不满足,多个线程可以同时进入临界区,同时读写临界资源,这种情况下还是会出现共享资源中数据的混乱。

条件变量主要有 condition_variablecondition_variable_any

  • condition_variable:需要配合 std::unique_lock\<std::mutex>进行 wait操作,也就是阻塞线程的操作。
  • condition_variable_any:可以和任意带有 lock()unlock()语义的 mutex搭配使用,也就是说有四种:
    • std::mutex:独占的非递归互斥锁。
    • std::timed_mutex:带超时的独占非递归互斥锁。
    • std::recursive_mutex:不带超时功能的递归互斥锁。
    • std::recursive_timed_mutex:带超时的递归互斥锁。

条件变量通常用于生产者和消费者模型,大致使用过程如下:

  1. 拥有条件变量的线程获取互斥量。
  2. 循环检查某个条件,如果条件不满足阻塞当前线程,否则线程继续向下执行:
    • 产品的数量达到上限,生产者阻塞,否则生产者一直生产。
    • 产品的数量为零,消费者阻塞,否则消费者一直消费。
  3. 条件满足之后,可以调用 notify_one() 或者 notify_all() 唤醒一个或者所有被阻塞的线程:
    • 由消费者唤醒被阻塞的生产者,生产者解除阻塞继续生产。
    • 由生产者唤醒被阻塞的消费者,消费者解除阻塞继续消费。

3.1 condition_variable

condition_variable的成员函数主要分为两部分:线程等待(阻塞)函数线程通知(唤醒)函数,这些函数被定义于头文件 <condition_variable>。

3.1.1 等待函数 wait()、wait_for()、wait_until()

函数原型

// wait()
// 函数①
void wait (unique_lock<mutex>& lck);
// 函数②
template <class Predicate>
void wait (unique_lock<mutex>& lck, Predicate pred);

// wait_for()
template <class Rep, class Period>
cv_status wait_for (unique_lock<mutex>& lck,
                    const chrono::duration<Rep,Period>& rel_time);
	
template <class Rep, class Period, class Predicate>
bool wait_for(unique_lock<mutex>& lck,
               const chrono::duration<Rep,Period>& rel_time, Predicate pred);

// wait_until()
template <class Clock, class Duration>
cv_status wait_until (unique_lock<mutex>& lck,
                      const chrono::time_point<Clock,Duration>& abs_time);

template <class Clock, class Duration, class Predicate>
bool wait_until (unique_lock<mutex>& lck,
                 const chrono::time_point<Clock,Duration>& abs_time, Predicate pred);
  • 函数① 调用此 wait() 函数的线程会被阻塞。
  • 函数② 的第二个参数是一个判断条件,是一个返回值为布尔类型的函数:
    • 该参数可以传递一个有名函数的地址,也可以直接指定一个匿名函数。
    • 表达式返回 false 当前线程被阻塞,表达式返回 true 当前线程不会被阻塞,继续向下执行。
  • 独占的互斥锁对象不能直接传递给 wait() 函数,需要通过模板类 unique_lock 进行二次处理,通过得到的对象仍然可以对独占的互斥锁对象做如下操作:
    • lock:锁定互斥锁。
    • try_lock:尝试锁定关联的互斥锁,若无法锁定,函数直接返回。
    • try_lock_for:试图锁定关联的可定时锁定互斥锁,若互斥锁在给定时长中仍不能被锁定,函数返回。
    • try_lock_until:试图锁定关联的可定时锁定互斥锁,若互斥锁在给定的时间点后仍不能被锁定,函数返回。
    • unlock:将互斥锁解锁。
  • 如果线程被该函数阻塞,这个线程会释放占有的互斥锁的所有权,当阻塞解除之后这个线程会重新得到互斥锁的所有权,继续向下执行。(这个过程是在函数内部完成的,其目的是为了避免线程的死锁)。
3.1.2 通知函数 notify_one()、notify_all()

函数原型

void notify_one() noexcept;
void notify_all() noexcept;
  • notify_one():唤醒一个被当前条件变量阻塞的线程。
  • notify_all():唤醒所有被当前条件变量阻塞的线程。

示例代码

std::mutex m;
std::condition_variable cv;
std::string data;
bool ready = false;
bool processed = false;
 
void worker_thread()
{
    // 等待直至 main() 发送数据,直到 ready = true
    std::unique_lock<std::mutex> lk(m);
    cv.wait(lk, []{return ready;});
 
    // 等待后,我们占有锁。
    std::cout << "Worker thread is processing data\n";
    data += " after processing";
 
    // 发送数据回 main()
    processed = true;
    std::cout << "Worker thread signals data processing completed\n";
 
    // 通知前完成手动解锁,以避免等待线程才被唤醒就阻塞(细节见 notify_one )
    lk.unlock();
    cv.notify_one();
}
 
int main() {
    std::thread worker(worker_thread);
 
    data = "Example data";
    // 发送数据到 worker 线程
    {
        std::lock_guard<std::mutex> lk(m);
        ready = true;
        std::cout << "main() signals data ready for processing\n";
    }
    cv.notify_one();
 
    // 等候 worker 处理完数据,直到 processed = true
    {
        std::unique_lock<std::mutex> lk(m);
        cv.wait(lk, []{return processed;});
    }
    std::cout << "Back in main(), data = " << data << '\n';
 
    worker.join();

    return 0;
}

运行结果

main() signals data ready for processing
Worker thread is processing data
Worker thread signals data processing completed
Back in main(), data = Example data after processing
3.1.3 生产消费者模型

可以使用条件变量来实现一个同步队列,这个队列作为生产者线程和消费者线程的共享资源。

示例代码

class SyncQueue {
private:
    list<int> m_queue;  // 存储队列数据
    mutex m_mutex;  // 互斥锁
    condition_variable m_notEmpty;  // 不为空的条件变量
    condition_variable m_notFull;  // 没有满的条件变量
    int m_maxSize;  // 产品最大生产量
    int loop = 1;
public:
    SyncQueue(int maxSize) : m_maxSize(maxSize) { }
    
    void put(const int& x) {
        unique_lock<mutex> locker(m_mutex);
        int i = x;

        // 生产
        while (m_queue.size() != m_maxSize) {
            m_queue.push_back(i);
            cout << i++ << " 被生产" << endl; 

            // 判断产品是否达到生产量上限
            if (m_queue.size() == m_maxSize) {
                cout << "产品已满, 请耐心等待..." << endl;
                // 阻塞线程
                m_notFull.wait(locker);
                
                // 退出循环
                if (loop++ == 2)
                    break;
            }     

            // 通知消费者去消费
            m_notEmpty.notify_one();     
        }
    }

    void take() {
        unique_lock<mutex> locker(m_mutex);
        // 判断是否还有产品
        while (!m_queue.empty()) {
            // 消费
            int x = m_queue.front();
            m_queue.pop_front();
            cout << x << " 被消费" << endl;

            
            // 没有产品了
            if (m_queue.empty()) {
                // 退出循环
                if (loop == 2)
                    break;
                cout << "任务队列已空,请耐心等待。。。" << endl;
                m_notEmpty.wait(locker);
            }

            // 通知生产者去生产
            m_notFull.notify_one();   
        }
        
    }

    bool empty() {
        lock_guard<mutex> locker(m_mutex);
        return m_queue.empty();
    }

    bool full() {
        lock_guard<mutex> locker(m_mutex);
        return m_queue.size() == m_maxSize;
    }

    int size() {
        lock_guard<mutex> locker(m_mutex);
        return m_queue.size();
    }
};

int main() {
    SyncQueue taskQ(3);
    auto produce = bind(&SyncQueue::put, &taskQ, placeholders::_1);
    auto consume = bind(&SyncQueue::take, &taskQ);

    thread t1;
    thread t2;

    t1 = thread(produce, 100);
    t2 = thread(consume);

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

    return 0;
}

运行结果

100 被生产
101 被生产
102 被生产
产品已满, 请耐心等待...
100 被消费
101 被消费
102 被消费
任务队列已空,请耐心等待。。。
103 被生产
104 被生产
105 被生产
产品已满, 请耐心等待...
103 被消费
104 被消费
105 被消费

程序一开始,生产者判断是否产品已达上限,如果是则被阻塞,否则生产产品,直到达到上限,然后被 m_notFull阻塞,等待唤醒;消费者判断是否有产品,没有则被阻塞,等待生产者生产,否则消费产品,直到消费全部产品,然后被 m_notEmpty阻塞,等待唤醒。


3.2 condition_variable_any

condition_variable_anycondition_variable类似,成员函数也是分为两部分:线程等待(阻塞)函数线程通知(唤醒)函数。其函数使用方法也十分类似,唯一的区别就是 condition_variable_any 可以和任意带有 lock()unlock()语义的 mutex搭配使用。

condition_variable配合 unique_lock使用更灵活一些,可以在任何时候自由地释放互斥锁,而 condition_variable_any如果和 lock_guard一起使用必须要等到其生命周期结束才能将互斥锁释放。但是,condition_variable_any可以和多种互斥锁配合使用,应用场景也更广,而 condition_variable只能和独占的非递归互斥锁(mutex)配合使用,有一定的局限性。


六、多线程异步操作

当需要获取线程返回的结果时,就不能通过 join() 得到结果,只能通过一些额外手段获得。如:定义一个全局变量,在子线程中赋值,在主线程中读这个变量的值,整个过程会显得十分繁琐。这时,就需要程序使用异步操作。


1. std::future

线程程序中的任务大都是异步的,主线程和子线程分别执行不同的任务,如果想要在主线中得到某个子线程任务函数返回的结果可以使用 C++11 提供的 std:future类(需要配合其他类使用)。

类的定义

// 定义于头文件 <future>
template< class T > class future;
template< class T > class future<T&>;
template<>          class future<void>;

类模板 std::future提供访问异步操作的机制:

  • (通过 std::asyncstd::packaged_taskstd::promise创建的)异步操作能提供一个 std::future对象给该异步操作的创建者。
  • 异步操作的创建者能用各种方法查询、等待或从 std::future提取值。若异步操作仍未提供值,则这些方法可能阻塞。
  • 异步操作准备好发送结果给创建者时,它能通过修改链接到创建者的 std::future的共享状态进行。

构造函数

// 构造函数①:默认无参构造函数
future() noexcept;
// 构造函数②:移动构造函数,转移资源的所有权
future( future&& other ) noexcept;
// 显示删除拷贝构造函数,不允许进行对象之间的拷贝
future( const future& other ) = delete;

1.1 operator=

由于 future 对象禁止复制操作,operator=会根据实际情况进行处理:

  • other 为右值:转移资源的所有权。释放任何共享状态并移动赋值 other 的内容给 *this 。赋值后, other.valid() == false。
  • other 为非右值:不允许进行对象之间的拷贝。

函数原型

future& operator=( future&& other ) noexcept;
future& operator=( const future& other ) = delete;

1.2 get()

get()函数用于取出 future 对象内部保存的数据。该函数是一个阻塞函数,当子线程的数据就绪后解除阻塞就能得到传出的数值了。

函数原型

T get();  // 泛型 future 模板的成员

T& get();  // future<T&> 模板特化的成员
void get();  // future<void> 模板特化的成员

1.3 wait()

wait()函数用于阻塞当前线程,等待子线程的任务执行完毕,任务执行完毕当前线程的阻塞也就解除了。

函数原型

void wait() const;

1.4 wait_for() 和 wait_until()

如果当前线程调用 wait()方法就会死等,直到子线程任务执行完毕将返回值写入到 future对象中,调用 wait_for()只会让线程阻塞一定的时长,但是这样并不能保证对应的那个子线程中的任务已经执行完毕了。

wait_until()wait_for()函数功能差不多,前者是阻塞到某一指定的时间点,后者是阻塞一定的时长。

函数原型

template< class Rep, class Period >
std::future_status wait_for( const std::chrono::duration<Rep,Period>& timeout_duration ) const;

template< class Clock, class Duration >
std::future_status wait_until( const std::chrono::time_point<Clock,Duration>& timeout_time ) const;

wait_until()wait_for()函数返回之后,并不能确定子线程当前的状态,因此需要判断函数的返回值,这样才能知道子线程当前的状态。

1.5 std::future_status

future_status是枚举类型,作用是指定 std::futurestd::shared_futurewait_forwait_until函数所返回的 future 状态。

常量解释
future_status::deferred子线程中的任务函仍未启动
future_status::ready子线程中的任务已经执行完毕,结果已就绪
future_status::timeout子线程中的任务正在执行中,指定等待时长已用完

2. std::promise

std::promise是一个协助线程赋值的类,它能够将数据和 future 对象绑定起来,为获取线程函数中的某个值提供便利。

类的定义

// 定义于头文件 <future>
template< class R > class promise;
template< class R > class promise<R&>;
template<> class promise<void>;

构造函数

// 构造函数①:默认无参构造函数,得到一个空对象
promise();
// 构造函数②:移动构造函数,转移资源的所有权
promise( promise&& other ) noexcept;
// 显示删除拷贝构造函数,不允许进行对象之间的拷贝
promise( const promise& other ) = delete;

2.1 get_future()

std::promise类内部管理着一个 future类对象,可以通过调用 get_future() 获取 future 对象。

函数原型

std::future<T> get_future();

2.2 set_value()

存储要传出的 value值,并立即让状态就绪,这样数据被传出其它线程就可以得到这个数据了。重载的第四个函数是为 promise<void>类型的对象准备的。

函数原型

void set_value( const R& value );
void set_value( R&& value );
void set_value( R& value );
void set_value();

2.3 set_value_at_thread_exit()

存储要传出的 value值,但是不立即令状态就绪。在当前线程退出时,子线程资源被销毁,再令状态就绪。

函数原型

void set_value_at_thread_exit( const R& value );
void set_value_at_thread_exit( R&& value );
void set_value_at_thread_exit( R& value );
void set_value_at_thread_exit();

2.4 promise 的使用

通过 promise传递数据的过程一共分为 5 步:

  • 在主线程中创建 std::promise对象。
  • 将这个 std::promise对象通过引用的方式传递给子线程的任务函数。
  • 在子线程任务函数中给 std::promise对象赋值。
  • 在主线程中通过 std::promise对象取出绑定的 future实例对象。
  • 通过得到的 future对象取出子线程任务函数中返回的值。

子线程任务函数执行期间,让状态就绪:

int main() {
    promise<int> pr;
    thread t1([](promise<int> &p) {
        p.set_value(100);
        this_thread::sleep_for(chrono::seconds(3));
        cout << "睡醒了...." << endl;
    }, ref(pr));

    future<int> f = pr.get_future();
    int value = f.get();
    cout << "value: " << value << endl;

    t1.join();
    return 0;

}

运行结果

value: 100
睡醒了....

示例程序中,子线程的匿名任务函数通过 p.set_value(100),传出数据并且激活状态。数据就绪后,外部主线程中的 int value = f.get()解除阻塞,并将得到的数据打印出来,3 秒钟之后子线程休眠结束,匿名的任务函数执行完毕。

子线程任务函数执行结束,让状态就绪:

int main() {
    promise<int> pr;
    thread t1([](promise<int> &p) {
        p.set_value_at_thread_exit(100);
        this_thread::sleep_for(chrono::seconds(3));
        cout << "睡醒了...." << endl;
    }, ref(pr));

    future<int> f = pr.get_future();
    int value = f.get();
    cout << "value: " << value << endl;

    t1.join();
    return 0;

}

运行结果

睡醒了....
value: 100

示例程序中,子线程的匿名任务函数通过 p.set_value_at_thread_exit(100),传出数据并且在任务函数执行完毕后并退出后激活状态。数据就绪后,外部主线程中的 int value = f.get()解除阻塞,并将得到的数据打印出来。因此在子线程休眠 3 秒之后,主线程才能得到传出的数据。

在外部主线程中创建的 promise对象必须要通过引用的方式传递到子线程的任务函数中。在实例化子线程对象的时候,如果任务函数的参数是引用类型,那么实参一定要放到 std::ref()函数中,表示要传递这个实参的引用到任务函数中。


3. std::packaged_task

std::packaged_task类包装了一个可调用对象包装器类对象(可调用对象包装器包装的是可调用对象,可调用对象都可以作为函数来使用)。

这个类可以将内部包装的函数和 future类绑定到一起,以便进行后续的异步调用,它和 std::promise有点类似,std::promise内部保存一个共享状态的值,而 std::packaged_task保存的是一个函数。

类的定义

// 定义于头文件 <future>
template< class > class packaged_task;
template< class R, class ...Args >
class packaged_task<R(Args...)>;

构造函数

// 构造函数①:默认无参构造,构造一个无任务的空对象
packaged_task() noexcept;
// 构造函数②:通过一个可调用对象,构造一个任务对象
template <class F>
explicit packaged_task( F&& f );
// 构造函数③:移动构造函数
packaged_task( packaged_task&& rhs ) noexcept;
// 显示删除拷贝构造函数,不允许进行对象之间的拷贝
packaged_task( const packaged_task& ) = delete;

3.1 get_future()

get_future()方法可以得到一个 future对象,基于这个对象可以得到传出的数据。

函数原型

std::future<R> get_future();

3.2 packaged_task 的使用

packaged_task其实就是对子线程要执行的任务函数进行了包装,和可调用对象包装器的使用方法相同,包装完毕之后直接将包装得到的任务对象传递给线程对象就可以了。

示例代码

int main() {
    packaged_task<int(int)> task([](int x) {
        return x += 100;
    });

    thread t1(ref(task), 100);

    future<int> f = task.get_future();
    int value = f.get();
    cout << "value: " << value << endl;

    t1.join();
    return 0;
}

通过 packaged_task类包装了一个匿名函数作为子线程的任务函数,最终的得到的这个任务对象需要通过引用的方式传递到子线程内部,这样才能在主线程的最后通过任务对象得到 future对象,再通过这个 future对象取出子线程通过返回值传递出的数据。


4. std::async

std::async可以直接启动一个子线程并在这个子线程中执行对应的任务函数,异步任务执行完成返回的结果也是存储到一个 future对象中,当需要获取异步任务的结果时,只需要调用 future类的get()方法即可,如果不关注异步任务的结果,只是简单地等待任务完成的话,可以调用 future类的 wait()或者 wait_for()方法。

函数原型

// 定义于头文件 <future>
// 函数①:直接调用传递到函数体内部的可调用对象,返回一个 future 对象
template< class Function, class... Args>
std::future<std::result_of_t<std::decay_t<Function>(std::decay_t<Args>...)>>
    async( Function&& f, Args&&... args );

// 函数②:通过指定的策略调用传递到函数内部的可调用对象,返回一个 future 对象
template< class Function, class... Args >
std::future<std::result_of_t<std::decay_t<Function>(std::decay_t<Args>...)>>
    async( std::launch policy, Function&& f, Args&&... args );

函数参数

  • f:可调用对象,这个对象在子线程中被作为任务函数使用。
  • Args:传递给 f 的参数(实参)。
  • policy:可调用对象・f 的执行策略。
策略说明
std::launch::async调用 async 函数时创建新的线程执行任务函数
std::launch::deferred调用 async 函数时不执行任务函数,直到调用了 future 的 get() 或者 wait() 时才执行任务(这种方式不会创建新的线程)

4.1 调用 async () 函数直接创建线程执行任务

示例代码

int main() {
    cout << "主线程ID: " << this_thread::get_id() << endl;

    // 调用函数直接创建线程执行任务
    future<int> f = async(launch::async, [](int x) {
        cout << "子线程ID: " << this_thread::get_id() << endl;
        this_thread::sleep_for(chrono::seconds(5));
        return x += 100;
    }, 100);

    future_status status;

    do {
        status = f.wait_for(chrono::seconds(1));
        if (status == future_status::deferred) {
            cout << "线程还没有执行..." << endl;
            f.wait();
        }
        else if (status == future_status::ready) {
            cout << "子线程返回值: " << f.get() << endl;
        }
        else if (status == future_status::timeout) {
            cout << "任务还未执行完毕, 继续等待..." << endl;
        }
    } while (status != future_status::ready);

    return 0;
}

运行结果

主线程ID: 140737348182976
子线程ID: 140737348167232
任务还未执行完毕, 继续等待...
任务还未执行完毕, 继续等待...
任务还未执行完毕, 继续等待...
任务还未执行完毕, 继续等待...
子线程返回值: 200

调用 async()函数时不指定策略或指定策略 launch::async 就是直接创建线程并执行任务。

4.2 调用 async () 函数不创建线程执行任务

示例代码

int main() {
    cout << "主线程ID: " << this_thread::get_id() << endl;
    // 调用函数直接创建线程执行任务
    future<int> f = async(launch::deferred, [](int x) {
        cout << "子线程ID: " << this_thread::get_id() << endl;
        return x += 100;
    }, 100);

    this_thread::sleep_for(chrono::seconds(5));
    cout << f.get() << endl;

    return 0;
}

运行结果

主线程ID: 140737348182976
子线程ID: 140737348182976
200

由于指定了 launch::deferred策略,因此调用 async()函数并不会创建新的线程执行任务,当使用 future类对象调用了 get()或者 wait()方法后才开始执行任务(此处一定要调用 wait_for()函数是不行的)。

通过测试程序输出的结果可以看到,两次输出的线程 ID 是相同的,任务函数是在主线程中被延迟(主线程休眠了 5 秒)调用了。


5. 总结

  • 使用 async()函数,是多线程操作中最简单的一种方式,不需要自己创建线程对象,并且可以得到子线程函数的返回值。
  • 使用 std::promise类,在子线程中可以传出返回值也可以传出其他数据,并且可选择在什么时机将数据从子线程中传递出来,使用起来更灵活。
  • 使用 std::packaged_task类,可以将子线程的任务函数进行包装,并且可以得到子线程的返回值。

六、GitHub 100 行线程池

简单来说,线程池就是一些创建好的线程,初始时他们都处于空闲状态。当有新的任务进来,从线程池中取出一个空闲的线程处理任务然后当任务处理完成之后,该线程被重新放回到线程池中,供其他的任务使用。当线程池中的线程都在处理任务时,就没有空闲线程供使用,此时,若有新的任务产生,只能等待线程池中有线程结束任务空闲才能执行。线程池可以解决资源频繁创建和释放的花销。

GitHub 100 行线程池

  • 并发:单核上,多个线程占用不同的 CPU 时间片,物理上还是串行执行的,但是由于每个线程占用的 CPU 时间片非常短,看起来就像是多个线程都在共同执行一样。
  • 并行:在多核或者多 CPU 上,多个线程是在真正的同时执行。

1. 线程池声明

class ThreadPool {
public:
    ThreadPool(size_t);  // size_t 启动线程的数量

    template<class F, class... Args>
    auto enqueue(F&& f, Args&&... args) 
        -> std::future<typename std::result_of<F(Args...)>::type>;

    ~ThreadPool();
private:
    // need to keep track of threads so we can join them
    std::vector<std::thread> workers;
    // the task queue
    std::queue<std::function<void()>> tasks;
    
    // synchronization
    std::mutex queue_mutex;
    std::condition_variable condition;
    bool stop;
};

Tips:

  • inline函数定义只能在声明它的文件中找到,所以 inline函数的定义不能和声明分开写在两个文件中。
  • 类的模板成员函数的实现需要放在 .h中。
    • C++ 标准明确表示,当一个模板不被用到的时侯它就不该被实例化出来;
    • 对 C++ 编译器而言,当调用函数的时候,编译器只需要看到函数的声明。当定义类类型的对象时,编译器只需要知道类的定义,而不需要知道类的实现代码,因此普通函数可以分开存放;
    • 模板函数和类模板,要求编译器在实例化模板时必须在上下文中可以查看到其定义实体;在看到实例化模板之前,编译器对模板的定义体是不处理的 —— 编译器无法预先知道 typename实参是什么,因此模板的实例化与定义体必须放到同一单元中。

2. 接口及成员变量

  • 接口
    • ThreadPool(size_t):构造函数
      • size_t:机器相关 unsigned类型,表示线程池线程个数
    • auto enqueue(F&& f, Args&&... args) -> std::future<typename std::result_of<F(Args...)>::type>:任务入队
      • F:任务函数
      • Args:任务函数参数
    • ~ThreadPool():析构函数
  • 成员变量
    • std::vector\<std::thread> workers:线程数组
    • std::queue\<std::function\<void()>> tasks:任务队列
    • std::mutex queue_mutex:互斥锁
    • std::condition_variable condition:条件变量
    • bool stop:析构标识

3. ThreadPool(size_t)

// the constructor just launches some amount of workers
inline ThreadPool::ThreadPool(size_t threads) : stop(false) {
    for(size_t i = 0; i < threads; ++i) {
		// 向 worker 中加入 threads 个线程
        workers.emplace_back([this] {
				
                while(true) {
                    std::function<void()> task;
                    // 下面是一个代码块,用作临界区
                    {
						// 锁定互斥锁
                        std::unique_lock<std::mutex> lock(this->queue_mutex);

						// 需要析构或者存在任务时
                        this->condition.wait(lock,
                            [this] { 
								return this->stop || !this->tasks.empty(); 
							});

						// 析构线程池并且没有任务时,如果存在任务需要先去执行任务,而非直接丢弃
                        if(this->stop && this->tasks.empty())  
                            return;

                        task = std::move(this->tasks.front());
                        this->tasks.pop();
                    }

                    task();  // 执行 task
                }
            }
        );
    }
}

Tips:

  • 构造函数执行顺序:
    1. 调用虚基类的构造函数
    2. 调用非虚拟基类的构造函数
    3. 调用类中成员对象的构造函数
    4. 调用自己的构造函数
  • 初始化列表:
    • 每个成员变量在初始化列表中只能出现一次。
    • 类中包含引用成员、const成员变量、自定义类型成员(且该类没有默认构造函数时),必须放在初始化列表位置进行初始化。
    • 尽量使用初始化列表进行初始化;对于自定义类型成员变量,一定会先使用初始化列表初始化。
    • 成员变量在类中声明次序就是其在初始化列表中的初始化顺序,与其在初始化列表中的先后次序无关。
  • lambda函数:C++ lambda函数 [ ]中有下面几中情形
    • [ ]```**:不捕获任何变量
    • [&]:按引用捕获外部作用域中所有变量
    • [=]:按值捕获外部作用域中所有变量
    • [=,&foo]:按值捕获外部作用域中所有变量,并按引用捕获 foo 变量
    • [bar]:按值捕获 bar变量,不捕获其他变量
    • [this]:捕获当前类中的 this指针,让 lambda 表达式拥有和当前类成员函数同样的访问权限;如果已经使用了 &或者 =,就默认添加此选项;捕获 this的目的是可以在 lamda中使用当前类的成员函数和成员变量
  • unique_lock:加强版 lock_guard,相比有如下优点
    • 创建时可以不锁定(使用参数 std::defer_lock)
    • 随时加锁解锁
    • 延迟锁定
    • 可以主动释放所有权
    • 条件变量需要该类型的锁作为参数
  • condition.wait(unique_lock<mutex>&, Predicate)
    • condition的互斥锁需要是 unique_lock类型
    • condition.wait()的第二个参数返回 false当前线程被阻塞,表达式返回 true当前线程不会被阻塞,继续向下执行。
  • emplace_back()push_back()
    • push_back():向容器尾部添加元素时,首先会创建这个元素,然后再将这个元素拷贝或者移动到容器中(如果是拷贝,则会销毁先前创建的这个元素)
    • emplace_back():直接在容器尾部创建这个元素,省去拷贝或移动元素的过程

4. enqueue(F &&f, Args &&… args)

// add new work item to the pool
template<class F, class... Args>
auto ThreadPool::enqueue(F&& f, Args&&... args)
    -> std::future<typename std::result_of<F(Args...)>::type> {  // 返回类型后置,返回类型是函数 F 的返回类型

	// std::result_of 获得一个函数的返回值
	// typename 在这里是用作限定从属名称
    using return_type = typename std::result_of<F(Args...)>::type;  

    // 封装任务
    auto task = std::make_shared<std::packaged_task<return_type()>>(
        std::bind(std::forward<F>(f), std::forward<Args>(args)...)  // std::forward 保持参数的左右值属性
    );
    
    std::future<return_type> res = task->get_future();

    {
        std::unique_lock<std::mutex> lock(queue_mutex);

        // don't allow enqueueing after stopping the pool
        if(stop) {
			throw std::runtime_error("enqueue on stopped ThreadPool");
		}
            
        // 具体执行的函数
        tasks.emplace([task]() { 
			(*task)(); 
		});
    }

    condition.notify_one();
    return res;
}

Tips:

  • 左值和右值:左值和右值都是针对表达式而言的
    • 左值:可以取地址的、有名字的;表达式结束后依然存在的持久化对象;
    • 右值:不能取地址的、没有名字;可以看作程序运行中的临时结果,右值引用可以避免复制提高效率。
  • std::result_of:主要用于目标函数定义的类型推导,虽然 auto也会自动推导类型,但是初始值不赋值时,auto是不能推导出目标类型,此时就需要使用 result_of推导类型。
  • make_shared_ptr:使用 make_shared初始化(而不是 new),可以防止使用原始指针创建多个引用计数体系
    • std::shared_ptr<Widget> spw(new Widget);
    • auto spw = std::make_shared<Widget>();
  • packaged_task:包装线程要执行的任务函数,包装完成后直接将包装得到的任务对象传递给线程对象即可。
  • bind:用来绑定函数调用的某些参数,可以将 bind函数看作一个通用的函数适配器,它接受一个可调用对象,生成新的可调用对象来适应原对象的参数列表。
    • 类成员函数:bind(c_func, &c_object, _1, _2),其中 _1是第一个参数,_2为第二个参数,以此类推。
    • 普通函数:bind(func, _1, _2)
  • future:同步机制中作为沟通桥梁。主线程和子线程是异步的,有不同的任务,如果想要在主线中得到某个子线程任务函数返回的结果可以使用 std:future类。
  • std::forward:保持参数的左右值属性。
  • ref():用于包装按引用传递的值。
    • 普通函数调用,有无 ref并没有什么影响。
    • 如果使用 std::bind时,是对参数直接拷贝,而不是引用;bind不知道生成的函数执行的时候,传递进来的参数是否还有效,所以它选择参数值传递而不是引用传递。此时所以如果想使用引用传递,需要使用 std::ref()std::cref()

5. ~ThreadPool()

// the destructor joins all threads
// 等所有线程结束后析构 join 阻塞
inline ThreadPool::~ThreadPool() {
    {
        std::unique_lock<std::mutex> lock(this->queue_mutex);
        this->stop = true;
    }

	// 唤醒所有被当前条件变量阻塞的线程。
    condition.notify_all();  

	// 等待所有线程执行完毕
    for(std::thread &worker: workers) {
		worker.join();
	}
}

6. 测试代码

int main() {
    ThreadPool pool(4);
    // results 存储每个任务返回的 future,这些任务将会在线程池中的各个线程中执行
    std::vector<std::future<int>> results;  
    for (int i = 0; i < 8; ++i) {
        results.emplace_back(pool.enqueue([i]() {

            std::cout << "begin:" << std::this_thread::get_id() << std::endl;
            std::this_thread::sleep_for(std::chrono::seconds(1));  // 模拟处理任务
            std::cout << "end" << std::this_thread::get_id() << std::endl;

            return i;
        }));
    }

    for (auto &&result : results) {
        std::cout << "wait for the result " << std::endl;
        result.wait();  // wait 等待结果出来
        std::cout << "task: " << result.get() << " finished" << std::endl;  // get 返回结果
    }

    return 0;
}
  • 0
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值