C++新经典 | C++ 查漏补缺(并发与多线程)

目录

一、线程、进程与并发

1.线程

2.并发

(1)多进程并发

(2)多线程并发

(3)总结

二、线程启动、结束与创建线程

1.Thread类

(1)join

(2)detach

(3)joinable

(4)线程id

2.线程入口函数参数

三、多线程数据共享

1.保护共享数据

(1)互斥量mutex

(2)std::lock_guard类模板

2.死锁

(1)std::lock函数模板

3.unique_lock类模板

(1)std::defer_lock

(2)try_to_lock参数

(3)成员函数lock                

(4)成员函数unlock        

(5)成员函数try_lock

(6)成员函数release

(7)unique_lock所有权

4.锁的粒度

5.单例模式的双重锁定

6.std::call_once

(1)std::once_flag

7.条件变量std::condition_variable

四、std::async和std::future、std::shared_future

1.std::future的成员函数

(1)get()

(2)wait()

2.std::shared_future

3.std::async额外参数

(1)std::launch::deferred

(2)std::launch::async

4.std::thread VS std::async

五、原子操作std::atomic

六、线程数量问题

1.线程创建的数量极限问题

2.线程创建数量建议


一、线程、进程与并发

1.线程

        线程并不是越多越好,每个线程都需要一个独立的堆栈空间(耗费内存,如一个线程占用1MB堆栈空间),而且线程之间的切换也要保存很多中间状态等,这也涉及上面提到过的上下文切换。所以,如果线程太多,上下文切换的就会很频繁,而上下文切换是一种必须但是没有价值和意义的额外工作,会耗费本该属于程序运行的时间。

        创建线程的数量最大一般都不建议超过200~300个,至于到底多少个合适,在实际项目中要不断调整和优化,有时候线程多了效率还会降低。

        主线程是自动启动的,所以一个进程中最少也是有一个线程(主线程)的。

2.并发

        两种手段可以实现并发:

  • 通过多个进程来实现并发,每个进程做一件事。这里所说的进程,指的是这种只包含一个主线程的进程,这种手段并不需要在程序代码中书写任何与线程有关的代码。
  • 在单独的一个进程中创建多个线程来实现并发,这种情况下就得书写代码来创建除主线程外的其他线程了(主线程不需要创建,进程一启动,主线程自动就存在并开始运行了)。

(1)多进程并发

        进程之间的通信手段比较多,如果是同一台计算机上的进程之间的通信,可以使用管道、文件、消息队列、共享内存等技术来实现,而在不同的计算机之间的进程通信可以使用socket(网络套接字)等网络通信技术来实现。由于进程之间数据保护问题,即便是在同一个计算机上,进程之间的通信也是挺复杂的。

(2)多线程并发

        多线程就是在单个的进程中创建多个线程。每个线程都是独立运行的,但是一个进程中的所有线程共享地址空间(共享内存),还有诸如全局变量、指针、引用等,都是可以在线程之间传递的,所以可以得出一个结论:使用多线程的开销远远小于多进程。

(3)总结

        一般来讲,建议优先考虑使用多线程的技术手段来实现并发而不是多进程。和多进程并发比较来讲,多线程并发的优缺点如下:

  • 优点:线程启动速度更快,更轻量级;系统资源开销更少;执行速度更快。
  • 缺点:使用起来有一定难度,要小心处理数据的一致性问题。

二、线程启动、结束与创建线程

void ThreadA()
{
    Sleep(1000);

    std::cout << "ThreadA" << std::endl;
}

void TestThread()
{
    std::thread _thread(ThreadA);

    std::cout << _thread.joinable() << std::endl;    //1

    _thread.join();

    std::cout << _thread.joinable() << std::endl;    //0

    std::cout << "TestThread end" << std::endl;
}
//---结果---
1
ThreadA
0
TestThread end

1.Thread类

    std::thread _thread(ThreadA);

        thread是C++标准库里面的类,这个类就是用来创建线程的。可以看到,用这个类生成一个对象,名字为_thread,里面是一个可调用对象(此处的可调用对象是函数ThreadA)作为thread构造函数的实参来构造这个thread对象。这行代码一执行,新线程创建出来了,并且立即开始执行新线程的初始函数ThreadA。

(1)join

        从字面翻译来看,join的意思是“加入/汇合”。换句话说,就是“阻塞”的意思——主线程等待子线程执行完毕,执行流程最终汇合到一起(子线程执行完毕,执行流程回归主线程并执行完main主函数)。

        一个书写良好的程序,应该是主线程等待子线程执行完毕后,自己才能最终退出。

(2)detach

        detach成员函数。翻译成中文,detach是“分离”的意思。所谓分离,就是主线程不和子线程汇合了,主线程执行主线程的,子线程执行子线程的,主线程不必等子线程运行结束,可以先执行结束,这并不影响子线程的执行。为什么会引入detach这样一个功能呢?说法就是:如果创建了很多子线程,让主线程逐个等待子线程结束,这种编程方法并不一定就是最好的,所以引入detach这种写法。

        线程一旦detach后,那么与这个线程关联的thread对象就会失去与这个线程的关联(那当然了,因为thread对象是在主线程中定义的),此时这个线程就会驻留在后台运行(主线程跟这个线程也就相当于失去联系了),这个新创建的线程相当于被C++运行时库接管了,当这个线程执行完后,由运行时库负责清理该线程相关的资源。

        针对一个线程,一旦调用了detach,就不可以再调用join了,否则会导致程序运行异常。

(3)joinable

        判断是否可以成功使用join或者detach(判断针对某个线程是否调用过join或者detach)。如果没有join或者detach过,则返回true。

(4)线程id

        每个线程(不管主线程还是子线程)实际上都对应着一个数字,这个数字用来唯一标识这个线程。因此,每个线程对应的数字都不同。也就是说,不同的线程,它的线程id必然不同。线程id可以用C++标准库里的函数std::this_thread::get_id来获取。

2.线程入口函数参数

  • 如果传递int这种简单类型参数,建议都使用值传递,不要使用引用类型,以免节外生枝。
  • 如果传递类对象作为参数,则避免隐式类型转换(例如把一个char*转成string,把一个int转成类A对象),全部都在创建线程这一行就构建出临时对象来,然后线程入口函数的形参位置使用引用来作为形参(如果不使用引用可能在某种情况下会导致多构造一次临时类对象,不但浪费,还会造成新的潜在问题)。这样做的目的无非就是想办法避免主线程退出导致子线程对内存的非法引用。
  • 建议不使用detach,只使用join,这样就不存在局部变量失效导致线程对内存非法引用的问题。
  • 所以如果真要用detach这种方式创建线程,记住不要往线程中传递引用、指针之类的参数。

        在C++中,当创建一个线程并传递参数时,这些参数默认情况下是按值传递的。也就是说,无论是否将它们作为引用类型进行处理,都会为每个线程创建一份副本。然而, 如果传递一个指针, 那么实际上拷贝的是指针值(即内存地址),而不是指针所指向的数据。这意味着新创建的线程将能够访问和修改原始数据,这时就需要注意作用域的问题。

        C++语言只会为const引用产生临时对象。

        临时对象不能作为非const引用参数,也就是必须加const修饰,这是因为C++编译器的语义限制。如果一个参数是以非const引用传入,C++编译器就有理由认为程序员会在函数中修改这个对象的内容,并且这个被修改的引用在函数返回后要发挥作用。但如果把一个临时对象当作非const引用参数传进来,由于临时对象的特殊性,程序员并不能操作临时对象,而且临时对象随时可能被释放掉,所以,一般来说,修改一个临时对象毫无意义。据此,C++编译器加入了临时对象不能作为非const引用的语义限制,意在限制这个非常规用法的潜在错误。

void TestCanShu(const int*i)
{
    std::cout << "i:"<<i << std::endl;
}
void main()
{
    int mvar = 10;
    std::cout << "mvar:" << &mvar << std::endl;

    std::thread  thread(TestCanShu, &mvar);            //地址相同
    thread.join();
}
//结果---
mvar:012FF960
i:012FF960
void TestCanShu1(int*i)
{
    std::cout << "i:"<<i << std::endl;
}
void main()
{
    int mvar1 = 20;
    std::cout << "mvar1:" << &mvar1 << std::endl;

    std::thread  thread1(TestCanShu1, &mvar1);               //地址相同
    thread1.join();
}
void TestCanShu2(const int&i)
{
    std::cout << "i:" << &i << std::endl;
}
void main()
{
    int mvar2 = 30;
    std::cout << "mvar2:" << &mvar2 << std::endl;

    std::thread  thread2(TestCanShu2, mvar2);    //拷贝了一份参数,虽然形参是引用类型,但是地址不同
    thread2.join();
}
void TestCanShu3( int&i)
{
    std::cout << "i:"<<&i << std::endl;
}
void main()
{
    int mvar3 = 40;
    std::cout << "mvar3:" << &mvar3 << std::endl;

    std::thread  thread3(TestCanShu3, std::ref(mvar3));   //地址相同
    thread3.join();
}

        为了数据安全,往线程入口函数传递类类型对象作为参数的时候,不管接收者(形参)是否用引用接收,都一概采用复制对象的方式来进行参数的传递。如果真的有需求明确告诉编译器要传递一个能够影响原始参数(实参)的引用过去,就得使用std::ref。

class TestThread {
public:
    int age;
    TestThread()
    {
        std::cout << "无参构造函数" << std::endl;
    }
    TestThread(int _age) :age(_age) {
        std::cout << "含参构造函数" << std::endl;
    }
};

void TestCanShu4(const TestThread& arg)
{
    std::cout << "arg:"<<&arg << std::endl;
}
void main()
{
    TestThread test(10);
    std::cout << "test:"<<&test << std::endl;
    std::thread  thread(TestCanShu4,test);             //地址不同    
    thread.join();
}
void main()
{
    TestThread test(10);
    std::cout << "test:"<<&test << std::endl;
    std::thread  thread1(TestCanShu4,std::ref(test));  //地址相同         
    thread1.join();
}

三、多线程数据共享

  • 只读的数据:一段共享数据,如果数据是只读的,每个线程都去读,那无所谓,每个线程读到的内容肯定都是一样的。
  • 有读有写:读的时候就不能写,写的时候就不能读,两个(或者多个)线程也不能同时写,两个(或者多个)线程也不能同时读。

1.保护共享数据

        当某个线程操作该共享数据的时候,就用一些代码把这个共享数据锁住,其他想操作这个共享数据的线程必须等待当前操作完成并把这个共享数据的锁打开,其他线程才能继续操作这个共享数据。这样都按顺序和规矩来访问这个共享数据,共享数据就不会被破坏,程序也就不会报异常。

(1)互斥量mutex

        互斥量,翻译成英文是mutex,互斥量实际是一个类,可以理解为一把锁。在同一时间,多个线程都可以调用lock成员函数尝试给这把锁头加锁,但是只有一个线程可以加锁成功,其他没加锁成功的线程,执行流程就会卡在lock语句行这里不断地尝试去加锁这把锁头,一直到加锁成功,执行流程才会继续走下去。

        互斥量需要小心使用,原则就是保护需要保护的数据,不要多也不要少,保护的数据少了(例如明明有两行代码都是操作共享数据的,却只保护了一行代码),没达到保护效果,程序执行可能还出现异常,保护的数据多了,就会影响程序运行效率,因为操作这段被保护的数据时,别人(其他线程)都在那里等着,所以操作完之后要尽快把锁头解锁,别人才能去操作这段共享数据。

#include <mutex>
std::mutex mutex;
void InputData()
{
    for (size_t i = 0; i < 100; i++)
    {
        mutex.lock();
        list.push(i);
        mutex.unlock();
    }
}

bool GetData()
{
    mutex.lock();
    if (!list.empty())
    {
        list.pop();
        mutex.unlock();          //注意
        return true;
    }
    mutex.unlock();              //注意
    return false;
}

        lock和unlock的使用规则:成对使用,有lock必然要有unlock,每调用一次lock,必然要调用一次unlock,不应该也不允许调用1次lock却调用了2次unlock,也不允许调用2次lock却调用1次unlock,否则都会使代码不稳定甚至崩溃。

(2)std::lock_guard类模板

        C++语言很体谅程序员,特意引入了一个叫作std::lock_guard的类模板。这个类模板非常体贴,它有一个很好的功能,就是即便开发者忘记了unlock也不要紧,它会替开发者unlock。

        std::lock_guard类模板直接可以用来取代lock和unlock,请注意,lock_guard是同时取代lock和unlock两个函数,也就是说,使用了lock_guard之后,就再也不需要使用lock和unlock了。

        std::lock_guard的工作原理很简单,这样理解:在lock_guard类模板的构造函数里,调用了mutex的lock成员函数,而在析构函数里,调用了mutex的unlock成员函数。

bool GetData()
{
    std::lock_guard<std::mutex> _mutex(mutex);
    if (!list.empty())
    {
        list.pop();
        return true;
    }
    return false;
}

        _mutex是随便起的变量名。仅当_mutex超出作用域或者所在函数返回的时候才会因为std::lock_guard<std::mutex>析构函数的执行而去调用mutex的unlock成员函数。

2.死锁

        死锁这个问题是由至少两个锁头也就是两个互斥量才能产生。每个线程都在这里等着对方线程把锁头解锁,你等我我等你。

        只要确保这两个互斥量上锁的先后顺序相同就不会死锁。而unlock的顺序则没有太大关系(建议谁后lock,谁就先unlock)。

(1)std::lock函数模板

        std::lock函数模板能一次锁住两个或者两个以上的互斥量(互斥量数量是2个到多个,不可以是1个),它不存在多个线程中因为锁的顺序问题导致死锁的风险。

        如果这些互斥量中有一个没锁住,就要卡在std::lock那里等着,等所有互斥量都锁住,std::lock才能返回,程序执行流程才能继续往下走。

        std::lock锁定两个mutex的特点是:要么两个mutex(互斥量)都锁住;要么两个mutex都没锁住,此时std::lock卡在那里不断地尝试锁这两个互斥量。所以,std::lock是要处理多个互斥量的时候才出场的。

3.unique_lock类模板

        unique_lock是一个类模板,它的功能与lock_guard类似,比lock_guard更灵活,但是占内存,运行效率差一点。unique_lock可以完全取代lock_guard。

(1)std::defer_lock

        (用这个defer_lock的前提是程序员不能自己先去lock这个mutex,否则会报异常)。std::defer_lock的意思就是初始化这个mutex,但是这个选项表示并没有给这个mutex加锁,初始化了一个没有加锁的mutex。

(2)try_to_lock参数

        系统会尝试用mutex的lock去锁定这个mutex,但如果没锁成功,也会立即返回,并不会阻塞在那里(使用std::try_to_lock的前提是程序员不能自己先去lock这个mutex,因为std::try_to_lock会尝试去lock,如果程序员先lock了一次,那这里就等于再次lock了,两次lock的结果就是程序卡死了)。

void TestLock()
{
    std::unique_lock<std::mutex> temp(mutex, std::defer_lock);
    if (temp.owns_lock())
    {
        std::cout << "defer_lock:已锁" << std::endl;     //不输出
    }
    //temp.lock();                                                 //加锁    方法一
    //temp.try_lock();                                             //加锁    方法二
    std::unique_lock<std::mutex> temp1(mutex, std::try_to_lock);   //加锁    方法三

    if (temp1.owns_lock())                              //输出
    {
        std::cout << "try_to_lock:已锁" << std::endl;
    }
}

(3)成员函数lock                

        给互斥量加锁,如果无法加锁,会阻塞一直等待拿到锁。

(4)成员函数unlock        

        针对加锁的互斥量,给该互斥量解锁,不可以针对没加锁的互斥量使用,否则报异常。在加锁互斥量后,随时可以用该成员函数再重新解锁这个互斥量。当然,解锁后,若需要操作共享数据,还要再重新加锁后才能操作。虽然unique_lock能够自动解锁,但是也可以用该函数手工解锁。所以,该函数也体现了unique_lock比lock_guard灵活的地方——随时可以解锁。

(5)成员函数try_lock

        尝试给互斥量加锁,如果拿不到锁,则返回false;如果拿到了锁,则返回true。

(6)成员函数release

        返回它所管理的mutex对象指针,并释放所有权。也就是这个unique_lock和mutex不再有关系。严格区别release和unlock这两个成员函数的区别,unlock只是让该unique_lock所管理的mutex解锁而不是解除两者的关联关系。

        一旦解除该unique_lock和所管理的mutex的关联关系,如果原来mutex对象处于加锁状态,则程序员有责任负责解锁。

(7)unique_lock所有权

        所有权指的就是unique_lock所拥有的这个mutex,unique_lock可以把它所拥有的mutex传递给其他的unique_lock。所以,unique_lock对这个mutex的所有权是属于可以移动但不可以复制的,这个所有权的传递与unique_ptr智能指针的所有权传递非常类似。

        如:通过函数返回一个unique_lock时,底层会调用移动构造函数。

4.锁的粒度

        有人也把用锁锁住的代码多少称为锁的粒度,粒度一般用粗细描述:

  • 锁住的代码少,粒度就细,程序执行效率就高。
  • 锁住的代码多,粒度就粗,程序执行效率就低(因为其他线程访问共享数据等待的时间会更长)。

5.单例模式的双重锁定

 static Test* GetInstance()
     {
         if (_instance == nullptr)
         {
             std::unique_lock<std::mutex> lock(mutex);
             if (_instance == nullptr) {
                 _instance = new Test();
             }
         }
         return _instance;
     }

        可以注意到,有两个if (_instance == nullptr),许多资料上叫这种写法为“双重锁定”或者“双重检查”。

6.std::call_once

        这是一个C++11引入的函数,这个函数的第二个参数是某个其他的函数名。假设有个函数,名字为a,call_once的功能就是能够保证函数a只被调用一次。读者都知道,例如有两个线程都调用函数a,那么这个函数a肯定是会被调用两次。但是,有了call_once,就能保证,即便是在多线程下,这个函数a也只会被调用一次。

        所以从这个角度讲,call_once也是具备互斥量的能力的,而且效率上据说比互斥量消耗的资源更少。

(1)std::once_flag

        std::once_flag,这是一个结构,在这里就理解为一个标记即可,call_once就是通过这个标记来决定对应的函数a是否执行,调用call_once成功后,call_once会反转这个标记的状态,这样再次调用call_once后,对应的函数a就不会再次被执行了。

7.条件变量std::condition_variable

        条件变量有什么用处呢?当然也是用在线程中,例如它用在线程A中等待一个条件满足(如等待消息队列中有要处理的数据),另外还有个线程B(专门往消息队列中扔数据),当条件满足时(消息队列中有数据时),线程B通知线程A,那么线程A就会从等待这个条件的地方往下继续执行。(当消息队列不为空的时候做一个通知,可以避免不断地判断消息队列是否为空。)

        std::condition_variable是一个类,一个和条件相关的类,用于等待一个条件达成。这个类需要和互斥量配合工作,用的时候要生成这个类的对象。

        notify_one函数会随机选择一个等待线程进行唤醒,而notify_all函数会唤醒所有等待线程。

class Test_condition_variable
{
private:
    std::condition_variable cv;
    std::queue<int> *list;
    static Test_condition_variable* _instance;
public:
     Test_condition_variable()
    {
         list = new std::queue<int>();
    }
     static  Test_condition_variable* GetInstance()
     {
         if (_instance == nullptr)
         {
             std::unique_lock<std::mutex> lock(mutex);
             if (_instance == nullptr) {
                 _instance = new Test_condition_variable();
                 static ReleaseInstance ri;
             }
         }
         return _instance;
     }
     class ReleaseInstance {
     public:
         ~ReleaseInstance()
         {
             if (_instance != nullptr)
             {
                 delete _instance;
                 _instance = nullptr;
             }
         }
     };
    void InputData(int data)
    {
        std::unique_lock<std::mutex> lock(mutex);
        list->push(data);
        if (list->size() > 0)
        {
            cv.notify_all();
        }
    }
    void OutputData()
    {
        std::unique_lock<std::mutex> lock(mutex);

        cv.wait(lock, [&]() {             //wait被InputData线程中的notify_all唤醒了
            if (!list->empty())           //如果没有第二个参数,则默认为一个返回false的谓词,即线程会一直等待直到被notify唤醒。即:如果唤醒之后, wait返回,执行流程走下来(注意现在互斥量是被锁着的)
            {                            //判断非常重要,因为唤醒这件事,存在虚假唤醒
                return true;             //如果唤醒之后,lambda表达式为true,那么wait返回,执行流程走下来(注意现在互斥量是被锁着的)
            }
            return false;                //如果唤醒之后,lambda表达式为false,那么这个wait又对互斥量解锁,然后又堵塞在这里等待被notify_all唤醒
            });
        //如果走到这里,说明list中一定有数据,注意此时互斥量是被锁着的
        auto value = list->front();
        list->pop();
    }
};

四、std::async和std::future、std::shared_future

        当我们希望线程返回一个结果时,除了可以把线程执行结果赋给一个全局变量,还可以用std::async和std::future来实现。

        std::async是一个函数模板,通常的说法是用来启动一个异步任务,就是说std::async会自动创建一个新线程(有时不会创建新线程)并开始执行对应的线程入口函数。启动起来这个异步任务后,它会返回一个std::future对象(std::future是一个类模板),这个对象里含有线程入口函数的返回结果。可以通过调用future对象的成员函数get来获取结果(future中会保存一个值,在将来某个时刻能够拿到。)。

#include <future>
int TestThreadReturn()
{
    std::chrono::milliseconds timer(5000);
    std::this_thread::sleep_for(timer);
    return -1;    //等待5秒返回-1
}
void TestFuture()
{
    std::future<int> result = std::async(TestThreadReturn);
    //result.wait();             //wait成员函数只是等待线程返回,但本身不返回结果。
    int value = result.get();    //当主线程执行到result.get()这行时,卡在这里,get成员函数等待线程结束(等待5秒)并返回结果-1。
    std::cout << value << std::endl;
}

1.std::future的成员函数

(1)get()

        get是很特殊的一个函数,不拿到值誓不罢休,程序执行流程必须卡在这里等待线程返回值为止。所以必须要保证,和std::future有关的内容一定要返回值或者一定要给result值,不然后续的result.get就会一直卡着。

        future对象的get函数被设计为移动语义,所以一旦调用get,就相当于把这个线程结果信息移动到result里面去了,所以再次调用future对象中的get就不可以了,因为这个结果值已经被移走了,再移动会报异常。(这就是std::future与std::shared_future的区别)

(2)wait()

        这个成员函数只是等待线程返回,但本身不返回结果。

2.std::shared_future

        std::shared_future和std::future一样,也是一个类模板。future对象的get函数是把数据进行转移,而shared_future从字面分析,是一个共享式的future,所以不难猜测到,shared_future的get函数应该是把数据进行复制(而不是转移)。这样多个线程就都可以获取到线程的返回结果。

3.std::async额外参数

        可以给async提供一个额外的参数,这个额外参数的类型是std::launch类型(一个枚举类型),来表示一些额外的含义。看一看这个枚举类型可以取哪些值。

(1)std::launch::deferred

        该参数表示线程入口函数的执行被延迟到std::future的wait或者get函数调用时,如果wait或者get没有被调用,则干脆这个线程就不执行了。

        如果后续调用wait或者get,则可以发现线程入口函数被执行了,但同时也会惊奇地发现,认知中的async调用根本没创建新线程,而是在主线程中调用的线程入口函数(主线程id和子线程id相同,说明没有创建子线程)。

(2)std::launch::async

        该参数表示在调用async函数时就开始创建并执行线程(强制这个异步任务在新线程上执行)。这意味着系统必须要创建出新线程来执行(主线程id和子线程id不同)。

(3)std::launch::async | std::launch::deferred

        如果std::async调用中不使用任何额外的参数,那么就相当于使用了std::launch::async | std::launch::deferred作为额外参数,这意味着系统自行决定是以同步(不创建新线程)或者异步(创建新线程)的方式运行任务。

4.std::thread VS std::async

        创建一个线程,一般都是使用std::thread方法。但是如果在一个进程中创建的线程太多导致系统资源紧张(或者是系统资源本来就很紧张的情况下),继续调用std::thread可能就会导致创建线程失败,程序也会随之运行崩溃。而且,std::thread这种创建线程的方式,如果这个线程返回一个值,想拿到这个结果也并不容易。

        std::thread是直接的创建线程,而std::async其实是叫创建异步任务,也就是说std::async可能创建线程,也可能不创建线程。同时,std::async还有一个独特的优点:这个异步任务返回的值程序员可以通过std::future对象在将来某个时刻(线程执行完)直接拿到手。

        由于系统资源的限制:

  • 如果用std::thread创建的线程太多,则很可能创建失败,程序会报异常并且崩溃。
  • 如果用std::async,一般就不会报异常崩溃,如果系统资源紧张导致无法创建新线程,std::async不加额外参数(或者额外参数是std::launch::async|std::launch::deferred)的调用就不会创建新线程而是后续谁调用了result.get来请求结果,那么这个异步任务就运行在执行这条get语句所在的线程上。也就是说,std::async不保证一定能创建出新线程来。如果程序员非要创建一个新线程出来,那就要使用std::launch::async这个额外参数,那么使用这个额外参数要承受的代价就是:当系统资源紧张时,如果非要创建一个新线程来执行任务,那么程序运行可能会产生异常从而崩溃。
  • 根据经验来讲,一个程序(进程)里面创建的线程数量,如果真有非常大量的业务需求,则一般以100~200个为好,最高也不要超过500个。因为请不要忘记,线程调度、切换线程运行都要消耗系统资源和时间。

五、原子操作std::atomic

        在自然界中,原子是很小的,没有比原子更小的物质了,那么在计算机的世界中,原子操作也有类似的意思,一般就是指“不可分割的操作”,也就是说这种操作的状态要么是完成,要么是没完成,不会出现一种半完成状态(半完成:执行到一半被打断)。

        互斥量可以达到原子操作的效果,所以,可以把原子操作理解成是一种不需要用到互斥量加锁(无锁)技术的多线程并发编程方式,或者可以理解成,原子操作是在多线程中不会被打断的程序执行片段。从效率上来讲,也可以认为,原子操作的效率更胜一筹。不然用互斥量就行了,谁还会用原子操作呢。

        另外有一点要意识到,互斥量的加锁一般是针对一个代码段(几行代码),而原子操作针对的是一个变量,而不是一个代码段。

        在C++11中,引入std::atomic来代表原子操作,这是一个类模板。这个类模板里面带的是一个类型模板参数,所以其实是用std::atomic来封装一个某类型的值。

std::atomic<int> value = 0;
void TestAtomic_Thread()
{
    for (size_t i = 0; i < 1000; i++)
    {
        value++;                 //原子操作
        //value += 1;              //原子操作
        //value = value + 1;       //不是原子操作
    }
}

        std::atomic<int>并不是所有的运算操作都是原子的。一般来讲,包含++、--、+=、-=、&=、|=、^=等简单运算符的运算是原子的,其他的一些包含比较复杂表达式的运算可能就不是原子的。

        原子操作适用的场合相对有限,一般常用于做计数(数据统计)之类的工作,例如累计发送出去了多少个数据包,累计接收到了多少个数据包等。试想,多个线程都用来计数,如果没有原子操作,那就跟上面讲的一样,统计的数字会出现混乱的情形,如果用了原子操作,所得到的统计结果数据就能够保持正确。

六、线程数量问题

1.线程创建的数量极限问题

        根据相关人士的测试,一般开2000个左右线程就是极限,再创建就会导致资源枯竭甚至程序崩溃。

2.线程创建数量建议

        当程序员采用一些比较独特的开发技术来开发程序时,如采用IOCP完成端口技术开发网络通信程序,往往会收到开发接口提供商提出的建议,如建议创建的通信线程数量等于CPU数量、等于CPU的数量*2、等于CPU的数量*2+2等诸如此类。建议遵从这些建议,因为这些建议是专业的,经过大量测试的,有权威性。

        其一:线程多的话,CPU在各个线程之间切换就要大量地保存数据和恢复数据,因为线程切换回来的时候要把线程中用到的如局部变量等数据也要恢复回来。显然,大量的保存和恢复数据是很占用CPU时间的,CPU都把时间花在保存和恢复数据上,它还有时间干正事吗?所以,当创建的线程数量过多时会发现,每个线程的执行都变得特别慢,整个系统的执行效率不升反降。

        其二:现在的操作系统都是多任务操作系统,虽然系统会把一个应用程序虚拟成一个独立的个体,看起来所有硬件都归这个独立的个体所用,但是系统的硬件资源必定是有限的,一个程序占用的多了,另外一个程序必然就占用的少了,当程序运行所需的资源超出了整个计算机硬件的负荷,该计算机的运行效率就直线下降,程序执行将变得异常缓慢。

        建议一个进程中所包含线程的数量尽量不要超过500个,以200个以内为比较好,就算是根据业务需要,一般来讲,也很少会用到超过200个线程的。如果业务太过庞大,单台计算机处理不了,那么就要考虑集群的解决方案,拼命榨取单计算机的硬件资源终究会有尽头。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

烫青菜

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值