C++并发编程(II)

Posted on Sun 14 February 2021 in programming

C++ threading

我们可以使用std::thread来创建一个线程,使用join()来等待一个线程执行结束。

#include <iostream>
#include <thread>

int main() {
    std::thread t([](){
        std::cout << "hello world." << std::endl;
    });
    t.join();
    return 0;
}

互斥量和自旋锁

我们可以通过std::mutex来创建一个互斥量,并使用lock()上锁,unlock()进行解锁。但是在实际使用中,我们一般不直接调用其成员函数,而是使用RAII语法的模板类std::lock_guard,这样做的好处是我们不需要再手动调用成员函数,并且不用担心异常处理和资源释放的问题。

#include <iostream>
#include <mutex>
#include <thread>

int v = 1;

void critical_section(int change_v) {
    static std::mutex mtx;
    std::lock_guard<std::mutex> lock(mtx); // 不用再手动释放资源

    // 执行竞争操作
    v = change_v;

    // 离开此作用域后 mtx 会被释放
}

int main() {
    std::thread t1(critical_section, 2), t2(critical_section, 3);
    t1.join();
    t2.join();

    std::cout << v << std::endl; // print 2 or 3
    return 0;
}

std::mutex是一种阻塞锁,当线程在等待互斥量的释放时,线程会被挂起,而不再消耗cpu时间。当其他线程释放互斥量后,操作系统会唤醒被挂起的线程。

在考虑高性能编程时,线程的挂起和被唤醒是很消耗时间的,我们可以考虑自旋锁的形式来提升效率。

自旋锁std::mutex的区别是,自旋锁是一种无阻塞锁。即当线程在等待自旋锁时,不会被挂起而是一直消耗cpu时间,不停尝试获取自旋锁。

一种可能的实现是这样的,我们可以借助std::atomic_flag这个原子操作类型来实现

#include <atomic>

struct spin_lock {
  spin_lock(std::atomic_flag &flag) : flag_(flag) {
    while (flag_.test_and_set(std::memory_order_acquire)); // loop until return false
  }
  ~spin_lock() { flag_.clear(std::memory_order_release); }
private:
  std::atomic_flag &flag_ = ATOMIC_FLAG_INIT;
};

我们也可以利用gcc提供的__sync_bool_compare_and_swap来实现自旋锁

class spinlock {
private:
    volatile bool lock_ = false;
public:
    void lock() {
        while (!__sync_bool_compare_and_swap(&lock_, false, true)) {
            // 执行除获取锁以外的策略,如暂时让出CPU等待或空转
        }
    }
    void unlock() {
        __sync_bool_compare_and_swap(&lock_, true, false);
    }
};

这里的__sync_bool_compare_and_swap的工作方式是比较lock_false,如果二者相等,就让lock_等于true,并返回true。如果lock_false不相等,就不改变lock_的值,并返回false.

此外,这里lock_变量使用volatile修饰。volatile是C++的一个类型修饰符,用于告诉编译器,声明为 volatile 的变量可能在编译器看不见的地方被修改,比如在程序之外被硬件修改或者在其他线程中被修改。其主要的两个作用是:

  1. 防止优化器优化:编译器在优化的时候可能会假设某个变量的值在没有明显的赋值的情况下不会发生改变,然后进行某些优化,如值的缓存、无效的代码剔除等等。但如果该变量被声明为volatile,编译器就不能做这样的假设,每次引用这个变量都必须从它所在的内存中取回,不能通过寄存器中缓存的值或表达式消除进行优化。

  2. 提供内存屏障:在某些架构下,编译器或硬件可能会对内存访问进行重新排序,而 volatile可以防止对这种特定变量的访问发生重新排序。

但是值得注意的是,volatile并不能保证复杂操作的原子性。比如++,--,+=等都不是原子操作。另外,C++中volatile不提供内存模型的支持,如果你希望进行线程间的同步,你可能需要使用C++11引入的std::atomic或者具有原子操作特性的函数。

再记录一个十分相似的使用mutable,mutable是一个专门用来改变类成员的关键字。当一个对象被声明为const,其所有成员都将成为只读,这时我们就不能在类的const成员函数中改变成员变量的值。但是有时我们需要在const成员函数中改变一些类成员的值,这时就可以使用mutable关键字来修饰那些需要改变的成员变量,即使在const成员函数中,这些被mutable修饰的成员变量的值也可以改变。

总结来说,这两者在语义和用途上是完全不同的,但是它们都改变了编译器默认的行为。volatile关键字改变了编译器对特定类型变量的优化策略,而mutable关键字对 const对象中的数据成员的修改权进行了重新定义。