C++多线程编程(一)

发起一个线程

c++11提供了c++风格的线程标准库,线程通过构建std::thread对象而启动,使用多线程我们需要包含头文件,该对象指明线程要运行的任务。最简单的任务就是运行一个普通函数,返回空,也不接收参数。函数在自己的线程上运行,等它一返回,线程即随之终止。

#include <iostream>
#include <thread>

void hello() {
    std::cout << "hello concurrent world";
}
int main() {
    std::thread t(hello);
    t.join();
}

线程也可以接收其他可调用对象,比如函数对象、lamda表达式。

#include <iostream>
#include <thread>

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

等待线程完成或守护线程

若需等待线程完成,那么可以在与之关联的std::thread实例上,通过调用成员函数join()实现。只要调用了join(),隶属于该线程的任何存储空间即会因此清除,std::thread对象遂不再关联到已结束的线程。事实上,它与任何线程均无关联。其中的意义是,对于某个给定的线程,join()仅能调用一次;只要std::thread对象曾经调用过join(),线程就不再可汇合(joinable),成员函数joinable()将返回false。
用std::thread对象的成员函数detach(),会令线程在后台运行,无法与之直接通信。假若线程被分离,就无法等待它完结,也不可能获得与它关联的std::thread对象。

int main() {
    std::thread t([]{
        std::cout << "hello concurrent world";
    });
    t.detach();
}

向线程传递参数

int main() {
    int a = 1;
    std::thread t([]{
        std::cout << a;
    });
    t.detach();
}

线程不能直接像如图所示的形式直接使用外部的变量,若需向新线程上的函数或可调用对象传递参数,需要向std::thread的构造函数增添更多参数。线程具有内部存储空间,参数会按照默认方式先复制到该处,新创建的执行线程才能直接访问它们。以下线程传入了两个外部变量a和b到线程空间中。

#include <iostream>
#include <thread>

void hello(const int a, const std::string& b) {
    std::cout << a << b;
}

int main() {
    int a = 1;
    std::string b = "hello thread";
    std::thread t(hello, a, b);
    t.join();
}

还有一种传入外部变量的方式是使用lamda表达式的捕获,以下代码捕获了两个外部变量a和b到线程空间中使用。

#include <iostream>
#include <thread>

int main() {
    int a = 1;
    std::string b = "hello thread";
    std::thread t([&]{
        a = 2;
        std::cout << a << b;
    });
    t.join();
}

防止悬空引用问题

当向线程中传入一个外部临时变量的引用并且以分离的方式运行时,可能外部方法已经执行完成并释放了该临时变量,此时在创建的线程中使用这个变量就会造成悬空引用的问题。

void f(int i,std::string const& s);
void oops(int some_param)
{
    char buffer[1024];                  //    ⇽---  ①
    sprintf(buffer, "%i",some_param);
    std::thread t(f,3,buffer);          //    ⇽---  ②
    t.detach();
}

在本例中,向新线程②传递的是指针buffer,指向一个局部数组变量①。我们原本设想,buffer会在新线程内转换成std::string对象,但在此完成之前,oops()函数极有可能已经退出,导致局部数组被销毁而引发未定义行为。这一问题的根源在于:我们本来希望将指针buffer隐式转换成std::string对象,再将其用作函数参数,可惜转换未能及时发生,原因是std::thread的构造函数原样复制所提供的值,并未令其转换为预期的参数类型,而提供的值是个数组,是按照指针类型传入的。

数据同步问题

在多个线程同时访问并更新同一份数据的时候,一个线程读取并修改内存里面的值,覆盖了另一个线程的更新,导致更新丢失,或者读取到了另一个线程的中间状态并修改导致恶性条件竞争。比如两个线程同时修改变量a=1的值让他递增,线程1读取a的值,然后递增把他改为2,同时线程2也要改变他的值,读取到1(线程1还未更新)然后递增改为2,此时就覆盖了线程1的修改结果。
防止恶性条件竞争有两个方向,最通用的方法是使用悲观锁,也就是互斥(mutex),一次只允许一个线程进入共享内存中修改数据,一个是乐观锁,检测多个线程的修改冲突并采取措施解决不一致问题。

在C++中,我们通过构造std::mutex的实例来创建互斥,调用成员函数lock()对其加锁,调用unlock()解锁。

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

int main() {
    int a = 1;
    std::mutex mutex;
    std::thread t1([&]{
        mutex.lock();
        for (int i = 0; i < 10; ++i) {
            a = a + 1;
        }
        mutex.unlock();
    });
    std::thread t2([&]{
        mutex.lock();
        for (int i = 0; i < 10; ++i) {
            a = a + 1;
        }
        mutex.unlock();
    });
    t1.join();
    t2.join();
    std::cout << a;
}

我不推荐直接调用成员函数的做法。原因是我们就必须记住,在函数以外的每条代码路径上都要调用unlock(),包括由于异常导致退出的路径。C++标准库提供了类模板std::lock_guard<>,针对互斥类融合实现了RAII手法:在构造时给互斥加锁,在析构时解锁,从而保证互斥总被正确解锁:

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

int main() {
    int a = 1;
    std::mutex mutex;
    std::thread t1([&]{
        std::lock_guard<std::mutex> gard(mutex);
        for (int i = 0; i < 10; ++i) {
            a = a + 1;
        }
    });
    std::thread t2([&]{
        std::lock_guard<std::mutex> gard(mutex);
        for (int i = 0; i < 10; ++i) {
            a = a + 1;
        }
    });
    t1.join();
    t2.join();
    std::cout << a;
}

防范死锁

有两个线程,都需要同时锁住两个互斥,才可以进行某项操作,但它们分别都只锁住了一个互斥,都等着再给另一个互斥加锁。于是,双方毫无进展,因为它们等待对方解锁互斥。上述情形称为死锁(deadlock)。为了进行某项操作而对多个互斥加锁,由此诱发的最大的问题之一正是死锁。

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

int main() {
    std::mutex mutex1;
    std::mutex mutex2;
    std::thread t1([&]{
        std::lock_guard<std::mutex> gard(mutex1);
        std::this_thread::sleep_for(std::chrono::milliseconds(100));
        std::lock_guard<std::mutex> gard2(mutex2);
        std::cout << "t1 finished";
    });
    std::thread t2([&]{
        std::lock_guard<std::mutex> gard(mutex2);
        std::this_thread::sleep_for(std::chrono::milliseconds(100));
        std::lock_guard<std::mutex> gard2(mutex1);
        std::cout << "t1 finished";
    });
    t1.join();
    t2.join();
}

以上代码就形成了一个死锁,线程t1获得了锁mutex1,然后等待100ms,此时线程t2已获得锁mutex2,同样等待线程t1获得了锁mutex1,接下来t1等待t2释放mutex2,t2等待t1释放mutex1,两个线程一直处于等待状态形成死锁。死锁多是由于持有多个互斥导致的,防范死锁可采用如下方法:

  • 按同样的顺序加锁和解锁

如果以上两个线程都是按照相同的顺序给mutex1和mutex2加锁,则可以避免死锁。更通用的,我们可以设计一种按层次加锁的数据结构,为每个互斥设计优先级,在运行期检查各互斥的优先级以保证顺序。

class hierarchical_mutex
{
    std::mutex internal_mutex;
    unsigned long const hierarchy_value;
    unsigned long previous_hierarchy_value;
    static thread_local unsigned long this_thread_hierarchy_value;    ⇽---  ①
    void check_for_hierarchy_violation()
    {
        if(this_thread_hierarchy_value <= hierarchy_value)    ⇽---  ②
        {
            throw std::logic_error("mutex hierarchy violated");
        }
    }

    void update_hierarchy_value()
    {
        previous_hierarchy_value=this_thread_hierarchy_value;    ⇽---  ③
        this_thread_hierarchy_value=hierarchy_value;
    }

public:
    explicit hierarchical_mutex(unsigned long value):
            hierarchy_value(value),
            previous_hierarchy_value(0)
    {}
    void lock()
    {
        check_for_hierarchy_violation();
        internal_mutex.lock();    ⇽---  ④
        update_hierarchy_value();    ⇽---  ⑤
    }
    void unlock()
    {
        if(this_thread_hierarchy_value!=hierarchy_value)
            throw std::logic_error("mutex hierarchy violated");    ⇽---  ⑨
        this_thread_hierarchy_value=previous_hierarchy_value;    ⇽---  ⑥
        internal_mutex.unlock();
    }
    bool try_lock()
    {
        check_for_hierarchy_violation();
        if(!internal_mutex.try_lock())    ⇽---  ⑦
        return false;
        update_hierarchy_value();
        return true;
    }
};
thread_local unsigned long
        hierarchical_mutex::this_thread_hierarchy_value(ULONG_MAX);    ⇽---  ⑧

以上代码封装了一个具有优先级的mutex叫做hierarchical_mutex,创建时需要传入一个unsigned long value类型的优先级,每次试图加锁都会拿hierarchical_mutex的优先级和当前线程的this_thread_hierarchy_value做比较②,如果比当前线程的优先级数字小则直接抛出异常,加锁失败,否则加锁成功并更新当先线程的his_thread_hierarchy_value为锁的优先级,因此在运行期保证了加锁的顺序③。

  • 使用std::lock()函数和std::scoped_lock<>模板可帮助防范死锁

死锁产生的原因多是对多个互斥加锁导致的,如果我们可以在一个线程中一次获取本次操作所需要的全部锁,则可以防范死锁。

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

int main() {
    std::mutex mutex1;
    std::mutex mutex2;
    std::thread t1([&]{
        std::unique_lock<std::mutex> lock1(mutex1, std::defer_lock);
        std::this_thread::sleep_for(std::chrono::milliseconds(100));
        std::unique_lock<std::mutex> lock2(mutex2, std::defer_lock);
        std::lock(lock1, lock2);
        std::cout << "t1 finished";
    });
    std::thread t2([&]{
        std::unique_lock<std::mutex> lock2(mutex2, std::defer_lock);
        std::this_thread::sleep_for(std::chrono::milliseconds(100));
        std::unique_lock<std::mutex> lock1(mutex1, std::defer_lock);
        std::lock(lock1, lock2);
        std::cout << "t1 finished";
    });
    t1.join();
    t2.join();
}

以上代码使用std::lock()配合std::unique_lock实现死锁避免,std::unique_lock比std::lock_guard更加灵活,std::lock_guard对象创建后会一直持有锁,而std::unique_lock可以创建时持有锁(传入std::adopt_lock,检测到死锁会抛出异常Resource deadlock avoided)或者延迟之后再加锁(传入std::defer_lock),直到std::lock(lock1, lock2)才加锁。std::scoped_lock原理与std::lock()一致。

参考文献:

[1] 《C++并发编程实战 安东尼·威廉姆斯 第二版 》 链接: https://pan.baidu.com/s/1-KzTyw-Sp61-bXFbVL2bJA 提取码: hack 《c++并发编程实战第二版pdf》互联网中的资源,如有侵权请联系删除。

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值