来聊聊C++中头疼的线程、并发

点击上方“AI算法修炼营”,选择加星标或“置顶”

标题以下,全是干货

1. 线程

在一个应用程序(进程)中同时执行多个小的部分(线程),这就是多线程。多个线程虽然共享一样的数据,但是却执行不同的任务。

线程启动、创建、结束

主线程执行完了,就代表整个进程执行完毕了,此时,一般情况下如果其他子线程没有执行完毕,那么这些子线程也会被操作系统终止。所以为了让子线程执行完就需要让主线程保持住。但是也有意外,不一定需要保持住

detach():传统多线程,主线程要等待子线程执行完毕,然后自己再最后退出, detach分离,也就是主线程和子线程不汇合了,各自独立执行,主线程执行完了,不影响我子线程。为什么引入detach?如果创建了很多子线程,让主线程逐个等待子线程结束,这个编程方法不好。

一旦detach()之后,与主线程的thread对象就会与主线程失去关联,此时子线程就驻留在后台。这个子线程就有运行时库负责清理相关线程的资源(守护线程)。一旦detach()调用后,就不能再用join()了。

joinable()判断是否可以成功使用join()或者detach()。

示例:用类对象创建线程

class Ta{
    public:
        void operator()() //需要有这个函数,且不能带参数
        {
            //你需要执行的操作
        }
};
int main(){
    Ta ta;
    thread thread(ta);//这样线程就会执行类的operator()
    //ta.join()
    ta.detach()//如果 ta中有引用或指针主线程的数据,就会出现不可意料的值。因为一旦主线程执行完,相应的资源就被释放了。
    //但是对象本身ta还在吗?不在了。那为什么thread还能正常运行?因为创建thread时创建的副本在子线程中运行。不行你可以显示实现一个拷贝构造函数看看。

}

线程传参

传递临时对象作线程参数

如果会对线程进行detach()。那么在创建线程时传参要特别注意:

  • 若传递int这种简单类型参数,建议都是值传递,不要用引用,防止节外生枝

  • 如果传递类对象,避免隐式转换。全部都在创建线程这一行就构建出临时对象来,然后在函数参数里,用引用来接,否则系统还会多构造一次对象。

所以建议不要轻易使用detach(),尽量使用join()。这样就不存在局部变量失效导致线程对内存的非法引用问题。

2. 并发的概念

两个或者多个任务(独立的活动)同时的进行:一个程序执行多个独立任务。

以往计算机,单核cpu:某一时刻只能执行一个任务,由任务系统调度,每秒钟进行多次所谓的任务切换。这是一种并发的假象,不是真正的并发,这种切换(上下文切换)是有时间开销的。比如操作系统要保存你操作状态,执行进度等信息,都需要时间,一会切换回来,恢复这些信息也需要时间。

多核cpu才是真正的并发(硬件并发)

使用并发的原因,主要是同时可以干多个事,提高效率

多线程并发

C++11可以通过多线程实现并发,这是一种比较底层、传统的实现方式。C++11引入了5个头文件来支持多线程编程:<atomic>/<thread>/<mutex>/<condition_variable>/<future>

#include <atomic>   // C++11 原子操作,限制并发程序对共享数据的使用,避免数据竞争#include <thread>   // 该头文件主要声明了std::thread类,另外std::this_thread命名空间也在该头文件中#include <mutex>    // C++11 互斥量Mutex。在多线程环境中,有多个线程竞争同一个公共资源,就很容易引发线程安全的问题#include <condition_variable>  // C++11 并发编程 主要包含了与条件变量相关的类和函数

线程并不是越多越好,每个线程,都需要一个独立的堆栈空间(1M),线程之间的切换要保存很多中间状态;

创建的线程数量不建议超过200-300个,在实际项目中可以不断调整和优化。

多线程通讯共享内存,全局变量,指针,引用等都可以实现。

共享内存带来问题:数据一致性问题,可以用信号量技术来解决。

多进程并发比多进程好, 启动速度快,更轻量级 系统资源开销更好,速度快,共享内存这种通讯方式比任何其他方式都快。缺点有一定难度,要小心处理数据的一致性问题。

主线程等待所有子线程运行结束,最后主线程结束。这样更容易写出稳定的程序。

vector<thread>  threads;  //用容器管理多线程

线程函数内,调用 std::this_thrad::get_id()可以获取线程id。

临界区,互斥量等以往多线程代码不能跨平台, poxi  可以跨平台,但是开发麻烦。

并发实现的常用框架

3. std::mutex 互斥访问

<mutex>是C++标准程序库中的一个头文件,定义了C++11标准中一些互斥访问的类与方法

其中std::mutex表示普通互斥锁,可以与std::unique_lock配合使用,把std::mutex放到unique_lock中时,mutex会自动上锁,unique_lock析构时,同时把mutex解锁。因此std::mutex可以保护同时被多个线程访问的共享数据,并且它独占对象所有权,不支持对对象递归上锁。

可以这样理解:各个线程在对共享资源操作前都尝试先加锁,成功加锁才能操作,操作结束解锁。(下图来自网络)

常用的成员函数有:

  1. 构造函数:std::mutex不支持copy和move操作,最初的mutex对象处于unlocked状态。

  2. lock函数:互斥锁被锁定。如果线程申请该互斥锁,但未能获得该互斥锁,则申请调用的线程将阻塞(block)在该互斥锁上;如果成功获得该互诉锁,则该线程一直拥有互斥锁直到调用unlock解锁;如果该互斥锁已经被当前调用线程锁住,则产生死锁(deadlock)。

  3. unlock函数:互斥锁解锁,释放调用线程对该互斥锁的所有权。

死锁问题

死锁问题,是至少由两个锁头也就是两个互斥量才能产生。线程A锁了金锁,需要去锁住银锁;然而线程B锁了银锁,需要去锁住金锁,这样A和B都在等待对方释放锁。就成了死锁。

死锁的一般解决方案:只要保证两个互斥量上锁的顺序一致就不会死锁。std::lock()函数模板 能力:一次锁住两个或者两个以上的互斥量。它不存这种因为在多个线层中,因为锁的顺序问题导致死锁的风险问题

std::lock() 如果互斥量中有一个没锁住,它就在那里等着。其情况就是多个锁要么都锁住,要么都没锁住。如果只锁了一个,另外一个没锁住,它立即把已经锁上的释放掉。

但是需要调用mutex.unlock()区分别释放锁。为了避免遗忘unlock,这里可以借用lock_guard和adopt_lock去解决这个问题。

 std::lock(my_mutex1,my_mutex2);
 std::lock_guard<std::mutex> mylock(my_mutex1,std::adopt_lock);
 std::lock_guard<std::mutex> mylock(my_mutex2,std::adopt_lock);

std::adopt_lock是一个结构体对象,起一个标记作用,作用就是表示这个互斥量已经lock了,在lock_guard()构造函数中不需要进行lock了,这样就不用去手动释放unlock了,借用lock_guard()的析构函数去unloc。

4. std::unique_lock 锁管理模板类

std::unique_lock为锁管理模板类,是对通用mutex的封装。

std::unique_lock对象以独占所有权的方式(unique owership)管理mutex对象的上锁和解锁操作,即在unique_lock对象的声明周期内,它所管理的锁对象会一直保持上锁状态;而unique_lock的生命周期结束之后,它所管理的锁对象会被解锁。因此用unique_lock管理互斥对象,可以作为函数的返回值,也可以放到STL的容器中。

其常用的成员函数为:

  1. unique_lock构造函数:禁止拷贝构造,允许移动构造;

  2. lock函数:调用所管理的mutex对象的lock函数;

  3. unlock函数:调用所管理的mutex对象的unlock函数;

在使用条件变量std::condition_variable时需要使用std::unique_lock而不能使用std::lock_guard。

unique_lock是个类模板,工作中,一般推荐使用lock_guard;

unique_lock比lock_guard灵活的多,但是效率要比lock_guard差一点,内存占用多一点。

unique_lock第二个参数:

  • std::adopt_lock

表示互斥量已经被lock了(你必须要把互斥量lock,否则会报异常 std::adopt_lock标记的效果就是“假设调用方线程已经拥有了互斥的所有权(已经lock成功了),lock_guard和unique_lock都可以调用这个标记。

  • std::try_to_lock

表示我们会尝试用mutex的lock去锁定这个mutex,如果没有锁定成功,就立即返回,并不会阻塞在哪里。用try_to_lock的前提是你自己不能先去lock.

std::mutex mymutex;
std::unique_lock<std::mutex> example(my_mutex,std::try_to_lock);
if(example.owns_lock()){
//拿到锁干什么。。。
}else{
// 没拿到锁干什么
}
  • std::defer_lock

前提是不能自己先lock,否则会报异常 defer_lock意思就是并没有给mutex加锁,初始化了一个没有加锁mutex

std::unique_lock<mutex> example(my_mutex,std::defer_lock);//没有加锁
/*用法一
example.lock();//自己手动加锁
//一些共享代码要处理
example.unlock();
//处理一些非共享代码
example.lock();//不需要手动开锁,当然你要手动开锁也可以,只是画蛇添足
*/

//用法二
if(example.try_lock()==true){
//拿到锁,做事
}else{
//没拿到锁做事
}

unique_lock成员函数

lock() //可以手动加锁,手动加锁后,你可以不用手动开锁,离开作用域后自动放锁。

 unlock() try_lock() 尝试给互斥量加锁,不阻塞。拿到就true,没拿到就false release(),返回它所管理的mutex对象指针,并释放所有权,也就是说这个unique_lock和mutext不再有关系。如果原来的mutex处于加锁状态,你接管过来必须负责开锁!!

无锁编程

为什么有时候需要unlock?因为你lock锁住的代码越少,执行越快,整个程序运行效率越高。有人也把锁头锁住的代码多少称为锁的粒度。粒度一般用粗细来描述. 锁住的代码少,这个粒度叫细,执行效率高. 锁住的代码多,这个粒度就粗,执行效率低. 合适的粒度,粒度太小可能遗漏保护代码.选择合适的粒度,是高级程序员的能力和实力的体现

unique_lock所有权的传递

std::unique_lock<std::mutex> example(my_mutex);
//example可以把自己的mutex所有权转移给其他unique_lock对象,但是不能复制所有权!
std::unique_lock<std::mutex>  example(std::move(example));
return std::unique_lock<std::mutex> //通过函数返回值也是一种所有权转移

5. std::condition_variable 条件变量

<condition_variable>是C++标准程序库中的一个头文件,定义了C++11标准中的一些用于并发编程时表示条件变量的类与方法等。

条件变量的引入是为了作为并发程序设计中的一种控制结构。当多个线程访问同一共享资源时,不但需要用互斥锁实现独享访问以避免并发错误(竞争危害),在获得互斥锁进入临界区后还需要检验特定条件是否成立:

  1. 若不满足该条件,拥有互斥锁的线程应该释放该互斥锁,使用unique_lock函数把自身阻塞(block)并挂到条件变量的线程队列中

  2. 若满足该条件,拥有互斥锁的线程在临界区内访问共享资源,在退出临界区时通知(notify)在条件变量的线程队列中处于阻塞状态的线程,被通知的线程必须重新申请对该互斥锁加锁。

条件变量std::condition_variable用于多线程之间的通信,它可以阻塞一个或同时阻塞多个线程。std::condition_variable需要与std::unique_lock配合使用。

常用成员函数:

(1)构造函数:仅支持默认构造函数。

(2)wait():当前线程调用wait()后将被阻塞,直到另外某个线程调用notify_*唤醒当前线程。当线程被阻塞时,该函数会自动调用std::mutex的unlock()释放锁,使得其它被阻塞在锁竞争上的线程得以继续执行。一旦当前线程获得通知(notify,通常是另外某个线程调用notify_*唤醒当前线程),wait()函数自动调用std::mutex的lock()。wait分为无条件被阻塞和带条件的被阻塞两种:

  1. 无条件被阻塞:调用该函数之前,当前线程应该已经对unique_lock<mutex> lck完成了加锁。所有使用同一个条件变量的线程必须在wait函数中使用同一个unique_lock<mutex>。该wait函数内部会自动调用lck.unlock()对互斥锁解锁,使得其他被阻塞在互斥锁上的线程恢复执行。使用本函数被阻塞的当前线程在获得通知(notified,通过别的线程调用 notify_*系列的函数)而被唤醒后,wait()函数恢复执行并自动调用lck.lock()对互斥锁加锁。

  2. 带条件的被阻塞:wait函数设置了谓词(Predicate),只有当pred条件为false时调用该wait函数才会阻塞当前线程,并且在收到其它线程的通知后只有当pred为true时才会被解除阻塞。因此,等效于while (!pred()) wait(lck).

(3)notify_all: 唤醒所有的wait线程,如果当前没有等待线程,则该函数什么也不做。

(4)notify_one:唤醒某个wait线程,如果当前没有等待线程,则该函数什么也不做;如果同时存在多个等待线程,则唤醒某个线程是不确定的(unspecified)。

简单的说就是,当std::condition_variable对象的某个wait函数被调用的时候,它使用std::unique_lock(通过std::mutex)来锁住当前线程。当前线程会一直被阻塞,直到另外一个线程在相同的std::condition_variable对象上调用了notification函数来唤醒当前线程。

6. async、future、packaged_task、promise异步编程


std::async & std::future

创建后台任务并返回值 。希望线程返回一个结果 std::async是个函数模板,用来启动一个异步任务,它返回一个std::future对象,std::future是一个类模板.。

异步任务:自动创建一个线程并开始执行对应的线程入口函数,他返回一个std::future对象 这个future对象里面含有线程入口函数所返回的结果,我们可以通过调用future对象的成员函数get()来获取结果.

通过额外向std::async()传递一个参数,该参数类型是std::launch类型(枚举类型),来达到一些目的

  • std::launch::deferred:表示线程入口函数调用被延迟到std::future的wait()或者get()函数调用时才执行. 如果wait()或者get()没有调用则不会执行线程.
    eg: std::async(std::launch::deferred,my_thread)可以测试线程id,延迟调用,其实没有创建新线程,是在主线程中调用的线程入口函数.

  • std::launch::async在调用async函数时,就开始创建线程 async函数默认用的就是std::launch::async标记

#include <future>
#include <thread>
#include<iostream>
int my_thread(){
    return std::this_thread::get_id();
}
int main(){
    //auto res=std::async(my_thread);
    std::future<int> res=std::async(my_thread);//创建了线程并执行,也可以成员函数创建.
    //A a;
    //std::future<int> res=std::async(&A::memberfunc,&a,&func_para);

    //cout<<res.get()<<endl; //卡在这里等待mythrea()执行完毕,拿到结果
    // res.get()只能调用一次!!
    res.wait();//等待线程返回,但本身并不返回结果.
}

std::packaged_task

打包任务,把任务包装起来。它是一个类模板,它的模板参数是各种可调用对象,通过std::package_task把各种可调用对象包装起来,方便将来作为线程入口函数调用。

packaged_task包装起来的可调用对象还可以直接调用。

int mythread1(int i){}
int my_thread2(){}

int main(){
    std::packaged_task<int(int)>> mypt(mythread1)  //把函数mythread1通过
    //packaged_task包装起来
    std::thread t1(std::ref(mypt),1)//线程直接开始执行,第二个参数作为线程入口函数的参数
    t1.join();
    std::future<int> res=mypt.get_future();
    //std::future对象里包含线程入口函数的返回结果
    cout<<res.get();
    
}

std::promise 类模板

我们能够在某个线程中给他赋值,然后我们可以在其他线程中,把这个值取出来. 总结:通过promise保存一个值,在将来某个时刻我们通过把一个future绑定到promise上,来获取这个绑定值.

void mythread(std::promise<int>& tmpp,int calc){
    int i=1/calc;
    int res=i+calc;
    tmpp.set_value(res)
    return ;
}

int main(){
    std::promise<int> myprom;
    std::thread t1(mythread,std::ref(&myprom),180);
    t1.join();
    //获取结果值
    std::future<int> fu=myprom.get_future();//promise和future绑定,用于获取线程返回值
    auto result=fu.get();
}

future其他成员函数\shared_future\atomic

std::future成员函数

std::future<int> res=std::async(my_thread);
std::future_status status=res.wait_for(std::chrono::seconds(1)); //等待1秒
if(status==std::future_status::timeout){
    //超时:表示等待了1秒线程没执行完
    //do something
}else if(status==std::future_status::ready) {
    //表示线程成功返回
    cout<<res.get()<<endl;
}else if(status==std::future_status::deferred){
    //如果async的第一个参数为std::launch::deferred,则本条件成立
    //线程被延迟执行
    res.get();
}
std::shared_future

std::future对象只能get一次,因为get函数的设计是一个移动语义.再次对对象进行get时,自然就没东西可以get了.

std::shared_future是个类模板,它的get()函数是复制数据,可以实现多次get

std::future<int> res=my.get_future();
//std::shared_future<int> res_s(std::move(res));
//或者
std::shared_furture<int> res_S(res.share());//此时res_s有值,res里空了

7. std::atomic 原子操作

<atomic>是C++标准程序库中的一个头文件,定义了C++11标准中一些表示线程、并发控制时进行原子操作的类与方法,主要声明了两大类原子对象:std::atomic和std::atomic_flag。

CAS是Compare And Swap的简称。意味比较并交换。

首先要说的是,为了保证Volatile的原子性操作,引入了三种方法。并且原子操作性能最高。他就是CAS。

那为什么CAS性能最好呢??

我们要来分析一下synchronized低效的原因:首先呢synchronized同步过程大家都有了解。不过这些过程不论是刷新主内存还是本地内存,都需要占用很多资源,所以性能低下。

再来了解CAS的原理:它有三个参数,当前内存值V,旧的预期值A,更新值B,只有当内存值和预期值相同时候,才会修改为B,否则就通过自旋锁的方式再次尝试,直到成功。(显然自旋次数过多也会造成影响)。然而CAS的过程其实没有获取和释放锁。它的运行和JMM内存模型没有关系。而是通过native方法调用本地方法直接和硬件打交道(使用了Unsafe unsafe = Unsafe.getUnsafe()。Unsafe 是CAS的核心类,提供的是硬件级别的原子操作)。因此性能更快。

CAS的不足有哪些?怎么解决的?
  1. 之前说过,CAS通过自旋的方式等待再次尝试,直到成功。因此有可能CAS长时间不成功,那么就需要CPU巨大的开销。

  2. 只能保证一个共享变量的原子操作,如果多个变量就需要使用锁。

  3. ABA问题:ABA问题简单的说就是存在这种可能性,由于我们的CAS会比较当前内存值和旧的预期值,如果相同就操作。如果有一个线程先把A改变成B,之后又变回A,那么另一个线程操作时候显然正确,它感知不到ABA的变化。就好比说,你喜欢一个姑娘很久了,姑娘现在没结婚,于是你就要和她结婚,但是在你回老家拿户口本的时候,这个姑娘和别人结婚,然后又离婚了,你回来的时候他仍然是单身。你品,你细品。问题大吧。

怎么解决ABA问题呢?于是呢就想出了版本控制这一个方法。我们在每一个变量上都加入一个版本号。改变的时候版本号增加,比较的时候版本号一同比较。

原子操作的主要特点是原子对象的并发访问不存在数据竞争,利用原子对象可实现数据结构的无锁设计。在多线程并发执行时,原子操作是线程不会被打断的执行片段。

(1)atomic_flag类

  1. 是一种简单的原子bool类型,只支持两种操作:test_and_set(flag=true)和clear(flag=false)。

  2. 跟std::atomic的其它所有特化类不同,它是锁无关的。

  3. 结合std::atomic_flag::test_and_set()和std::atomic_flag::clear(),std::atomic_flag对象可以当作一个简单的自旋锁(spin lock)使用。

  4. atomic_flag只有默认构造函数,禁用拷贝构造函数,移动构造函数实际上也禁用。

  5. 如果在初始化时没有明确使用宏ATOMIC_FLAG_INIT初始化,那么新创建的std::atomic_flag对象的状态是未指定的(unspecified),既没有被set也没有被clear;如果使用该宏初始化,该std::atomic_flag对象在创建时处于clear状态。

  • test_and_set:返回该std::atomic_flag对象当前状态,检查flag是否被设置,若被设置直接返回true,若没有设置则设置flag为true后再返回false。该函数是原子的。

  • clear:清除std::atomic_flag对象的标志位,即设置atomic_flag的值为false。

(2)std::atomic类

  1. std::atomic提供了针对bool类型、整形(integral)和指针类型的特化实现。每个std::atomic模板的实例化和完全特化定义一个原子类型。

  2. 若一个线程写入原子对象,同时另一个线程从它读取,则行为良好定义。

  3. 原子对象的访问可以按std::memory_order所指定建立线程间同步,并排序非原子的内存访问。

  4. std::atomic可以以任何可平凡复制(Trivially Copyable)的类型T实例化。

  5. std::atomic既不可复制亦不可移动。

  6. ATOMIC_VAR_INIT(val):可以由构造函数直接执行此宏初始化std::atomic对象。

std::atomic 常用的成员函数:

  1. std::atomic::store(val) 函数将参数 val 复制给原子对象所封装的值。

  2. std::atomic::load() 读取被原子对象封装的值。

  3. std::atomic::exchange(val) 读取并修改被封装的值,exchange 会将 val 指定的值替换掉之前该原子对象封装的值,并返回之前该原子对象封装的值,整个过程是原子的.

  4. atomic() 默认构造函数,由默认构造函数创建的 std::atomic 对象处于未初始化(uninitialized)状态,对处于未初始化(uninitialized)状态 std::atomic对象可以由 atomic_init 函数进行初始化。

  5. atomic (T val) 初始化构造函数,由类型 T初始化一个 std::atomic对象。

  6. atomic (const atomic&) 拷贝构造函数被禁用。


参考文献:

1. blog.csdn.net/liuxuejia

2. blog.csdn.net/fengbingc

3. cnblogs.com/wangshaowei

4. blog.csdn.net/fengbingc

5. cnblogs.com/taiyang-li/

6. blog.csdn.net/tanningzh

7.https://zhuanlan.zhihu.com/p/134099301 

8.https://zhuanlan.zhihu.com/p/136861784

本文仅作学术交流,如有侵权,请联系删文



目标检测系列秘籍一:模型加速之轻量化网络秘籍二:非极大值抑制及回归损失优化秘籍三:多尺度检测秘籍四:数据增强秘籍五:解决样本不均衡问题秘籍六:Anchor-Free视觉注意力机制系列Non-local模块与Self-attention之间的关系与区别?视觉注意力机制用于分类网络:SENet、CBAM、SKNetNon-local模块与SENet、CBAM的融合:GCNet、DANetNon-local模块如何改进?来看CCNet、ANN
语义分割系列一篇看完就懂的语义分割综述最新实例分割综述:从Mask RCNN 到 BlendMask超强视频语义分割算法!基于语义流快速而准确的场景解析CVPR2020 | HANet:通过高度驱动的注意力网络改善城市场景语义分割




评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值