C++11 多线程语法

线程

std::thread(C++11)

创建 std::thread,一般会绑定一个底层的线程。

std::thread 的实现是基于C的 pthread

若该 thread 还绑定好函数对象,则即刻将该函数运行于thread的底层线程:

// 通过绑定lambda表达式来创建线程,并立即执行
std::thread t1([]{
    //do somthing immediately
});

std::thread 其实是一种封装好遵循RAII思想的线程对象,当 std::thread 对象被释放时,会在析构函数自动释放对应的底层线程:

do{
    std::thread t1([]{...});
}while(0);// 退出作用域时,不管 t1 是否执行完任务,强制销毁线程

std::thread 对象是不支持拷贝的(因为拷贝线程),而应当用 std::move 语法来转移线程控制权给另一个 std::thread 对象:

std::thread t2 = std::move(t1);
  • joinable():是否可以阻塞至该thread绑定的底层线程运行完毕(倘若该thread没有绑定底层线程等情况,则不可以join)

  • join():本线程阻塞直至该thread的底层线程运行完毕

  • detach():该thread绑定的底层线程分离出来,任该底层线程继续运行(thread失去对该底层线程的控制

std::jthread(C++20)

C++20 引入了 std::jthread 类,和 std::thread 不同在于:它的析构函数里会自动调用 join() 函数

do{
    std::jthread jt1([]{...});
}while(0);// 退出作用域时,当前线程需等到 jt1 线程执行完任务后才能继续往下执行 

线程独立变量 thread_local(C++11)

类似于 static 变量,区别在于 thread_local 变量在每个线程是各自独立的,并在线程结束时释放。

void func(){
    thread_local int a = 0;
    ++a;
    ++a;
    std::cout << a;
}
int main(){
	std::thread t1(func);
	std::thread t2(func);
    t1.join();
    t2.join();
}
// 两个线程里的 a 是各自独立的,因此输出为22

互斥变量

为了避免多线程对共享变量的一段操作会发生冲突,引入了互斥体和锁。

std::mutex(C++11)

互斥体,一般搭配锁使用,也可自己锁住自己。

  • lock():请求锁住自己
  • unlock():解锁自己

若 std::mutex 对象没被上锁,则 lock 后该 std::mutex 对象变为上锁状态,并本线程继续往下执行;

若 std::mutex 对象已被上锁,则 lock 后本线程将阻塞等待,直至该对象解锁才立即再次上锁(调用一次unlock只满足一次上锁请求,其余请求仍将继续阻塞等待),并本线程才继续往下执行;

std::mutex mtx;
int var = 0;	// 共享变量
std::thread t1([&]{
    mtx.lock();
    ...			// 对共享变量var操作
    mtx.unlock();
});
std::thread t2([&]{
    mtx.lock();
    ...			// 对共享变量var操作
    mtx.unlock();
});

std::shared_mutex(C++17)

在读者-写者模型中,三种情况是不会造成数据冲突的:

  • n个读者在读取数据
  • 1个写者在写入数据
  • 无人读取,无人写入

而这两种情况会造成数据冲突:

  • n个写者在写入数据
  • 有读者在读取数据时,也有写者也在写入数据

对读取和写入都采用 mutex 上锁解锁会降低并发性能,因为这样n读者就不能同时读取数据了,为此可以提供一种读写锁支持以上场景:std::shared_mutex

lock_shared():读者身份请求锁住

unlock_shared():读者身份解锁

lock():写者身份请求锁住

unlock():写者身份解锁

std::lock_guard(C++11)

单纯使用 std::mutex,可能会经常忘记手动 unlock。

而 std::lock_guard 是符合RAII思想的简单锁,即对象构造时自动请求上锁,释放时自动解锁。

std::mutex mtx;
int var = 0;	// 共享变量
std::thread t1([&]{
    std::lock_gurad lck(mtx);	// 自动让 mtx 上锁
    ...							// 对共享变量var操作
	}							// std::lock_guard 对象退出作用域时,自动让 mtx 解锁
);

std::unique_lock(C++11)

std::unique_lock 和 std::lock_gurad 类似,都是符合RAII思想的,对象构造时自动请求上锁,释放时自动解锁。

但它更多功能也更灵活的锁,随时可解锁或重新锁上(减少锁的粒度),性能耗费比 lock_gurad 高一些。适用灵活的区域的多线程互斥操作。

std::mutex mtx;
int var = 0;	// 共享变量
std::thread t1([&]{
    std::unique_lock lck(mtx);	// 自动让 mtx 上锁
    ...							// 对共享变量var操作
    lck.unlock();				// 可随时解锁
    ...
    lck.lock();					// 可随时重新上锁
	}							// std::unique_lock 对象退出作用域时,又会检查对象是否处于上锁状态,若是则自动让 mtx 解锁
);
  • std::unique_lock(mtx,第二个参数):构造函数的第二个参数可填入不同的flag,来执行不同的行为
    • std::defer_lock: 构造时不立即上锁,而是之后通过调用 lock 来上锁
    • std::try_to_lock:构造时会调用mtx.try_lock()而不是mtx.lock(),之后可以用owns_lock()判断上锁是否成功
  • lock():请求锁住 mutex 对象
  • unlock():解锁 mutex 对象
  • try_lock():即使请求上锁失败,本线程仍不会阻塞等待,而是继续往下执行;请求上锁成功时返还 true 否则返还 false
  • try_lock_for(time):与try_lock()类似,不过它如果在这个时间段内上锁成功则立即返还 true,否则等待这段时间后返还 false;类似还有 try_lock_until(timepoint)

std::scoped_lock(C++17)

std::scoped_lock 和 std::lock_guard 类似,析构也符合 RAII 思想,只不过它可以同时对多个 mutex 同时上锁(可用于解决需要同时占有多种资源的死锁问题)。

std::mutex mtx1;
int var1 = 0;	// 共享变量
std::mutex mtx2;
int var2 = 0;	// 共享变量
std::thread t1([&]{
    std::scoped_lock lck(mtx1,mtx2);	// 自动让 mtx1,mtx2 上锁
    ...									// 对共享变量var1,var2操作
	}									// std::lock_guard 对象退出作用域时,自动让 mtx1,mtx2 解锁
);

多线程安全的 vector,访问者模式(应用)

对 vector 的任意操作都上锁解锁,可以保证数据不冲突;但如果某个线程对 vector 有连续多个操作,那么重复的上锁解锁会浪费大量性能(毕竟这连续多个操作是可以保证不互相冲突的)。

因此可以主动提供一个对 vector 的上锁解锁操作,无论是单操作的粒度还是连续多个操作的粒度都可以兼用;使用访问者设计模式,可以通过访问者对象方便地对整个 vector 上锁解锁(构造时上锁,析构时自动解锁):

class MTvector {
    std::vector<int> m_arr;
    std::mutex m_mtx;
public:
    class Accessor {
        MTvector& m_that;
        std::unique_lock<std::mutex> m_guard;
    public:
        Accessor(MTvector& that)
            : m_that(that), m_guard(that.m_mtx){
        }
        void push_back(int val)const {
            return m_that.m_arr.push_back(val);
        }
        size_t size()const {
            return m_that.m_arr.size();
        };
    };
    Accessor access() {
        return { *this };
    }
};

下面是使用例子:

MTvector arr;
auto func = [&]() {
    auto axr = arr.access();
    for (int i = 0; i < 100; i++) {
        axr.push_back(i);
    }
};
std::thread t1(func);
std::thread t2(func);

原子变量

原子变量的意思就是指支持原子操作的变量,所谓原子操作是指极小且不可分割的操作(操作要不全部做完,要不一点没做),这样就可以保证不同线程在做某些操作时不会被其他线程中途插一脚。

例如,两个线程t1,t2都想对a做传统加法(+1),逻辑上期望最终a应该为2:

a = 0 
// 但是因为传统加法操作是会分割若干个子操作的,因此可随时被其它线程打断插入,因此可能的子操作序列如下:
1. t1:读取变量到rax寄存器
2. t1:rax寄存器+=1
3. t2:读取变量到rax寄存器
4. t2:rax寄存器+=1
5. t1:寄存器值写入变量
6. t2:寄存器值写入变量
// 最终结果为a=1

而为了保证操作的原子性,硬件专门提供了原子操作相关的指令(例如+=):

load xadd %eax,(%rdx)

当 CPU 识别到这些原子操作指令时,会锁住内存总线,放弃乱序执行等优化策略(将该指令视为一个同步点,强制同步掉之前所有的内存操作),从而保证该操作是 原子 (atomic) 的,不会加法加到一半另一个线程插一脚进来。

// 一种可能序列:
1. t1:a+=1(原子操作)
2. t2:a+=1(原子操作)
// 另一种可能序列:
1. t2:a+=1(原子操作)
2. t1:a+=1(原子操作)
// 都能保证最终结果a=2

C++ 11 封装了若干种支持原子操作的数据对象,使得多线程不会同时访问原子变量。利用原子变量可实现多线程的无锁设计,相比 mutex 有锁设计会更加轻量级,效率更高,也无需手动上锁解锁,代码更加简洁直观。

std::atomic_flag(C++11)

它是一个支持原子操作的布尔类型,可支持两种原子操作:

实际上 std::mutex 可用 std::atomic_flag 实现

  • test_and_set(): 如果 atomic_flag 对象被设置,则返回 true; 如果 atomic_flag 对象未被设置,则设置之,返回false
  • clear():清除atomic_flag对象

std::atomic<T>(C++11)

对 int, char, bool 等基本数据类型进行原子性封装(其实是特化模板)。

  • store(value):修改被封装的值,相当于 = value

  • load(): 读取被封装的值

  • fetch_add(value):相当于 += value,并返还旧值

  • exchange(value):相当于 = value,并返还旧值

原子操作一般不支持返还修改后的新值(例如支持i++而不支持++i)

  • compare_exchange_strong(T& old,value):读取,比较是否与 old 相等,相等则写入 value 并返还 true,否则将原子变量的值写入 old 并返还 false

compare_exchange_strong 是逻辑最复杂的原子操作

条件变量

条件变量一般是用来实现多个线程的等待队列,即某个线程通知(notify)有活干了,则等待队列中的其它线程(一个或所有线程)就会被唤醒,开始干活。

std::condition_variable(C++11)

  • wait(std::unique_lock<std::mutex>& lock, std::function<bool()> pred):将当前线程添加到该 lock 对象的等待队列,当被唤醒时检查是否满足 pred()==true ,若满足则当前线程往下执行,否则继续在队列中阻塞等待。若不填入参数pred,则默认永远满足条件,即只要唤醒就无条件立即往下执行
  • notify_one():唤醒等待队列中第一个等待的线程
  • notify_all():唤醒等待队列中所有等待的线程
std::condition_variable cv;	// 条件变量
std::mutex mtx;
bool condition = false;		// 将要用于pred()
std::thread t1([&]()
    {
        do {
            std::unique_lock<std::mutex> lck(mtx);
            // 加入等待队列,并阻塞等待到被唤醒为止
            cv.wait(lck, [&]() {
                return condition;
                });
            std::cout << "t1:被唤醒成功" << std::endl;
            condition = false;
        } while (true);
    }
);
std::thread t2([&]()
    {
        std::cout << "t2:尝试唤醒一个线程" << std::endl;
        cv.notify_one();	// 执行完后,t1并未被唤醒
        std::cout << "t2:满足条件后,尝试唤醒一个线程" << std::endl;
        condition = true;
        cv.notify_one();	// 执行完后,t1被唤醒
    }
);
  • wait_for(time) / wait_until(timePoint):与 wait 类似,接受 chrono 时间段/时间点作为参数

生产者-消费者模型(应用)

std::vector<int> buffer;
const int MAX_BUFFER_SIZE = 5;
std::mutex mtx;
std::condition_variable cv1; // 用于唤醒生产者的队列,即缓冲区有空位时唤醒
std::condition_variable cv2; // 用于唤醒消费者的队列,即缓冲区有生产物时唤醒
auto producerFunc = [&](const std::string& name) {
    do {
        Sleep(100);
        std::unique_lock<std::mutex> lck(mtx);
        // buffer未满时可以生产
        cv1.wait(lck, [&]() {return buffer.size() < MAX_BUFFER_SIZE; });
        // 生产
        buffer.emplace_back(rand());
        std::cout << name << ":生产+1,当前数量:" << buffer.size() << std::endl;
        // 通知消费者可以消费
        cv2.notify_one();
    } while (true);
};
auto consumerFunc = [&](const std::string& name) {
    do {
        Sleep(100);
        std::unique_lock<std::mutex> lck(mtx);
        // buffer不空时可以消费
        cv2.wait(lck, [&]() {return !buffer.empty(); });
        // 消费
        buffer.pop_back();
        std::cout << name << ":消费+1,当前数量:" << buffer.size() << std::endl;
        // 通知生产者可以生产
        cv1.notify_one();
    } while (true);
};
std::thread t1(producerFunc, std::string("p1"));
std::thread t2(producerFunc, std::string("p2"));
std::thread t3(consumerFunc, std::string("c1"));
std::thread t4(consumerFunc, std::string("c2"));
t1.join();
t2.join();
t3.join();
t4.join();

状态产生(简单了解就算了)

std::promise<T>(C++11)

构造时,产生一个未就绪的状态(包含存储的 T 值和是否就绪的状态)。可设置 T 值,并让状态变为ready。

  • set_value():设置状态的T值,并让状态变为ready,则绑定的 std::future 对象可 get()
  • get_future():状态绑定到 std::future 对象

std::packaged_task<Func>(C++11)

构造时绑定一个函数对象,也产生一个未就绪的状态。通过 thread 启动或者仿函数形式启动该函数对象。

但是相比 promise,没有提供 set_value() 公用接口,而是当执行完绑定的函数对象,其执行结果返回值或所抛异常被存储于能通过 std::future 对象访问的状态中。

  • get_future():状态绑定到 std::future 对象

状态获取结果(简单了解就算了)

std::future<T>(C++11)

用于访问状态(即获取就绪的状态结果)。当 future 的状态还不是 ready 时,就调用一个绑定的 promise, packaged_task 等的析构函数,会在期望里存储一个异常。

  • get():获得状态结果的值,若状态不是 ready,则阻塞直至 ready
  • wait():不获取任何值(一般用在void函数对象的packaged_task),若状态不是 ready,则阻塞直至 ready
  • share():生成一个 std::shared_future 对象,并将状态转移给它,同时本对象会变成 not-vaild

std::shared_future<T>(C++11)

std::future 在某种场景中有局限性:在很多线程等待时,只有一个线程能获取等待结果。

std::shared_future 与 std::future 类似,但 shared_future 可以拷贝、多个 shared_future 可以共享某个状态的最终结果(即共享状态的某个值或者异常)

也就是说当需要多个线程等待同一事件的结果(即多处访问同一个状态)时,则要用 std::shared_future 来替代 std::future

shared_future 可通过某个 future 对象隐式转换,或通过 future::share() 显示转换得到

  • get():获得共享状态的值,若共享状态不是 ready,则阻塞直至 ready
  • wait():不获取任何值(一般用在void函数对象的packaged_task),若共享状态不是 ready,则阻塞直至 ready

封装的异步操作

std::async() (C++11)

std::async(flag, func)

async是一个函数,它负责异步执行一个函数func,其函数执行完后的返还值绑定给使用 std::async 的 std::futrue(其实是封装了 std::thread, std::packged_task 的功能,使异步执行一个任务更为方便)。

若用创建 std::thread 执行异步行为,硬件底层线程可能不足,产生错误;而std::async将这些底层细节掩盖住,如果使用默认参数则与标准库的线程管理组件一起承担线程创建和销毁、避免过载、负责均衡的责任。

所以尽量使用以任务为驱动的 async 操作设计,而不是以线程为驱动的 thread 设计。

std::async 中的第一个参数是启动策略,它控制std::async的异步行为,我们可以用三种不同的启动策略来创建std::async:

  • std::launch::async:保证异步行为,即传递函数将在单独的线程中执行
  • std::launch::deferred:当其他线程调用 get()/wait() 来访问共享状态时,将调用非异步行为
  • std::launch::async|std::launch::deferred :是默认参数(async的第一个参数不填入flag时将采用默认flag)。有了这个启动策略,它可以异步运行或不运行,这取决于系统的负载
std::future<std::string> resultFromDB = std::async(std::launch::async, [&](){
    std::string result;
    ...					// query DB
    return result;
});
std::string data = resultFromDB.get();

参考

posted @ 2018-09-05 13:48  KillerAery  阅读(7277)  评论(3编辑  收藏  举报