C++11多线程编程 一:多线程概述

C++11多线程编程 一:多线程概述

C++11多线程编程 二:多线程通信,线程同步,锁

C++11多线程编程 三:锁资源管理和条件变量 

C/C++基础,Boost创建线程、线程同步 


0 背景

        多线程可以提高程序的并发性,提高并发性有两种方式,一个是多线程,一个是多进程,但多线程对系统的消耗更加少一些,并且线程和进程的平均效率是差不多的。
我们在执行一个程序的时候,其会占用CPU资源,而CPU资源是有限的,假如有8个CPU,然后需要执行100个应用程序,但是为什么会同时运行呢?,其实这是一个假象,CPU会把单位的时间平均分成若干份,每一份都是CPU的时间片,纳秒或者微秒级别,时间片分好之后让系统进行调度,每个线程在执行的时候,都需要抢占CPU的时间片,如果抢到了,那这个线程就执行,如果没有抢到,就处于一个就绪状态,就绪态的线程没有CPU的使用权,因为没有抢到CPU的使用功权,所以就需要不停的去抢,抢到之后就可以运行了,就变成了运行态,用完时间片之后,就又重新恢复到了就绪态,继续抢占时间片,循环往复,通过这样的快速切换,我们看到的这个线程是在运行,其实他也是在走走停停的,只不过因为视觉暂留现象,看不出他暂停了。
 
        Linux内核只有进程,没有线程,线程就是一个轻量级的进程(LWP:light weight process),在 Linux 环境下线程的本质仍是进程。在计算机上运行的程序是一组指令及指令参数的组合,指令按照既定的逻辑控制计算机运行。操作系统会以进程为单位,分配系统资源,可以这样理解,进程是资源分配的最小单位,线程是操作系统调度执行的最小单位。

        多个线程共用同一个地址空间,而进程有自己独立的地址空间,线程更加节省系统资源,效率不仅可以保持,而且能够更高
        在一个地址空间中多个线程独享:每个线程都有属于自己的栈区,寄存器 (内核中管理的),栈区就是内存四区中存储临时变量的区域,寄存器是CPU里面的资源,寄存器资源是由内核区来管理的,就不过多展开了。
        在一个地址空间中多个线程共享代码段,堆区,全局数据区,打开的文件(文件描述符表)都是线程共享的

        操作系统在分配资源的时候,最小单位是一个虚拟地址空间,这一个地址空间被一个进程使用,每个进程对应一个虚拟地址空间,一个进程只能抢一个 CPU 时间片(如果地址空间中住着10个线程,那么这个10个线程都可以抢CPU的时间片,所以线程虽然占用的系统资源少,但是他的效率并没有降低),一个地址空间中可以划分出多个线程,在有效的资源基础上,能够抢更多的 CPU 时间片
 
        什么是地址空间呢?就是我们启动一个应用程序,就会得到下图所示的地址空间,其实就是内存四区的增强版
        代码段就是下面图中的.text段
        堆区就是存储new出来的或者malloc出来的资源
        全局数据区就是全局变量或者静态变量存储的位置,就是.bss段或者.data段

        这个地址空间包含了内存四区,还包括另外的一些东西,上图是一个进程里面所有的东西,每启动一个进程都占用这样的一个地址空间,如果再启动一个进程就得到了第二个这样的虚拟地址空间。而多个线程共用同一个地址空间。

        假如操作系统里面只有一个线程,那么单线程和单进程是一样的,因为没有其他的程序跟它进行抢占,那他他就会一直被得到执行,也就是上图的那个单线程所示的那样,他就被一直执行。如果有多个线程,CPU在处理多个线程的时候, 把CPU的时间分成了若干个碎片,每个线程只抢一个,抢到了就执行,执行完之后,放弃CPU的使用权,这个多线程的图画的太理想了,在系统后台这些抢占都是随机的,可能线程1在前3次抢到了CPU的时间片,线程2可能前10次都抢到了,线程3可能2-5次抢到了。都有可能,并且每次执行都不一样。多线程运行他是一个无序的运行状态。
 
CPU的调度和切换:线程的上下文切换比进程要快的多
        上下文切换:进程 / 线程分时复用 CPU 时间片,在切换之前会将上一个任务的状态进行保存(保存在寄存器里面),下次切换回这个任务的时候,从寄存器加载这个状态继续运行,任务从保存到再次加载这个过程就是一次上下文切换。
线程更加廉价,启动速度更快,退出也快,对系统资源的冲击小
        因为线程退出可能最多只是销毁一份虚拟地址空间,而多个进程退出要销毁多份虚拟地址空间,所以在释放的时候,线程释放的也会更加快一些
什么时候需要考虑多进程呢?
        假如我们有一个应用程序a,需要在运行的a中启动程序b,就是启动另外一个进程,这时我们就需要基于进程来完成这件事情,如果我们只是执行任务,而不是启动磁盘上的另外一个应用程序,只考虑线程就可以了
 
        假如一个CPU有8个核心,那么就可以同时处理8个任务,如果说你有16个线程,他还是利用分时复用CPU时间片的方式被处理,理想状态下,每个核处理两个线程,这两个线程需要分时复用CPU时间片,如果有160个线程,每个核的负担就有20个线程,20个线程分时复用CPU时间片,来回切换比较耗时,时间都被消耗了,在处理多任务程序的时候使用多线程比使用多进程要更有优势,但是线程并不是越多越好,如何控制线程的个数呢?处理复杂的算法 (主要是 CPU 进行运算,压力大),线程的个数 = CPU 的核心数 (效率最高)
        文件 IO 操作:文件 IO 对 CPU 使用率不高,因此可以分时复用 CPU 时间片,线程的个数 = 2 * CPU 核心数 (效率最高),IO操作并不需要进行计算,需要内存和磁盘之间进行交互,交互的压力不在于CPU,而在于内存到磁盘之间的瓶颈,因为内存是电子设备,而硬盘有可能是一个机械硬盘,机械设备和电子设备在进行数据交互的时候运行效率是差了很多的,差了100万倍。


1.1 第一个线程代码示例

创建示例

        多线程编程当中,每一个程序运行都至少会有一个线程,一般的main函数都作为主线程的入口,这里面是一个进程包含一个主线程,一个进程里面包含多个子线程,所以一般在主线程当中(也就是main函数中)再去启动一个子线程。
 
        需要包含头文件:#include <thread>
        用到的类:thread th(ThreadMain); 线程创建并启动
        阻塞等待函数:th.join();当子线程退出之后才解除阻塞
        如果不阻塞等待子线程退出的话,也就是把th.join();去掉,那么主线程运行完,就把th对象给销毁了,而这时子线程还在运行,这时就会弹窗报错。

 
        针对子线程,运行完回调函数就自动退出,而主线程会把在内部创建的线程对象同时给销毁,也就是说应该先退出子线程再销毁线程对象。

#include <thread>
#include <iostream>
//Linux 中要链接动态库 -lpthread
using namespace std;
 
void ThreadMain(){
    cout << "Sub  thread ID " << this_thread::get_id() << endl; // get_id()获取当前线程ID
    for (int i = 0; i < 5; i++){
        cout << "sub thread " << i << endl;
        this_thread::sleep_for(std::chrono::milliseconds(100));//100ms
    }
    cout << "end sub thread " << endl;
}

int main(int argc, char* argv[]){

    int num = thread::hardware_concurrency();
    cout << "CPU number: " << num << endl;  // 因为我用是虚拟机只分配了6个核心,所以显示6

    cout << "main thread ID " << this_thread::get_id() << endl;

    thread th(ThreadMain);    //线程创建并启动
    cout << "wait sub thread exit... " << endl;
    th.join();                //阻塞等待子线程退出

    cout << "end main thread " << endl;
    return 0;
}
#编译
g++ -o main main3.c -lpthread

 结果:

        线程的退出:我们这里创建了一个子线程,假如我们让他运行10s后再退出,这里就涉及到一个问题,假如里面是一个死循环,那么就会把单个CPU的资源耗尽,而有些任务处理的时候并不是计算任务,而只是在等待某一个结果,那么这个时候就可以在子线程中选择释放CPU资源,这里使用其中的一种方式,就是sleep,sleep就是当前线程释放CPU多长时间。

        上面第7行和第23行,这两个打印输出可能是同时执行的,因为是多核并发,因此最后反应在屏幕上可能导致两行字叠在一块儿了。

join()函数

        作用是让主线程等待该子线程完成,然后主线程再继续执行

        一个子线程只能调用join()和detach()中的一个,且只允许调用一次。可以调用joinable()来判断是否可以成功调用join()或detach()。

#include <thread>
#include <iostream>

using namespace std;

void test(){
    cout << "子线程开始执行!" << endl;
    //do something
    cout << "子线程执行完毕!" << endl;
}

int main(){
    cout << "主线程开始执行!" << endl;

    thread t(test);

    // boolalpha 头文件#include <iostream> 让输出流将bool解析成为true或者 false   noboolalpha反之
    cout << "join()是否可调:" << std::boolalpha << t.joinable() << endl;

    t.join();   //主线程等待子线程

    cout << "join()是否可调:" << std::noboolalpha << t.joinable() << endl;
    cout << "主线程执行完毕!" << endl;
    return 0;
}

detach()函数

        从图中我们可以发现fun和main是交叉着输出的,并不是先输出fun中的内容,那么detach的作用就是将主线程与子线程分离,主线程将不再等待子线程的运行,也就是说两个线程同时运行,当主线程结束的时候,进程结束。

        在detach的时候,子线程将脱离主线程的控制,子线程独立分离出去并在后台运行,相当于linux中的守护进程,那么该子线程会由运行时库托管。当主线程结束的时候,进程也就结束,那么该进程的所有线程也会结束,被分离出去的子线程会由运行时库回收其资源。

线程的命名空间 std::this_thread

        进程一共有五种状态分别为:创建态,就绪态,运行态,阻塞态(挂起态),退出态(终止态) ,其中创建态和退出态维持的时间是非常短。我们主要是清楚就绪态 , 运行态 , 挂起态,同样线程创建后也有这五个状态。

sleep_for()

        指定线程阻塞一定的时间长度 duration 类型,之后解除阻塞。

        调用这个函数的线程会马上从运行态变成阻塞态并在这种状态下休眠一定的时长,因为阻塞态的线程已经让出了 CPU 资源,代码也不会被执行,所以线程休眠过程中对 CPU 来说没有任何负担。程序休眠完成之后,会从阻塞态重新变成就绪态就绪态的线程需要再次争抢 CPU 时间片,抢到之后才会变成运行态,这时候程序才会继续向下运行。

this_thread::sleep_for(std::chrono::seconds(1));//1s秒
this_thread::sleep_for(std::chrono::milliseconds(1000));//1000ms毫秒
this_thread::sleep_for(std::chrono::microseconds(1000*1000));//1000*1000us微妙
this_thread::sleep_for(std::chrono::nanoseconds(1000*1000*1000));//1000*1000*1000ns纳秒

sleep_until()

        指定线程阻塞到某一个指定的时间点 time_point类型,之后解除阻塞。

        C++11 中提供了日期和时间相关的库 chrono,chrono 库主要包含三种类型的类:时间间隔duration、时钟clocks、时间点time point。也就是这俩函数要用到的参数

yield()

        在线程中调用这个函数之后,处于运行态的线程会主动让出自己已经抢到的 CPU 时间片,最终变为就绪态,这样其它的线程就有更大的概率能够抢到 CPU 时间片了。使用这个函数的时候需要注意一点,线程调用了 yield () 之后会主动放弃 CPU 资源,但是这个变为就绪态的线程会马上参与到下一轮 CPU 的抢夺战中,不排除它能继续抢到 CPU 时间片的情况

        std::this_thread::yield() 的目的是避免一个线程长时间占用CPU资源,从而导致多线程处理性能下降
        std::this_thread::yield() 是让当前线程主动放弃了当前自己抢到的CPU资源,但是在下一轮还会继续抢


1.2 thread对象生命周期和线程等待和分离

 

         这里面导致错误的原因有多个,第一个首先是主线程退出了,那么我们先要保证主线程不要退出

 但是结果还是跟上面一样。

我们希望主线程和子线程同时运行,然后我们又不想维护th这个对象(当然还有一种方式就是维护这个对象)那怎么办?

#include <thread>
#include <iostream>
//Linux -lpthread
using namespace std;
bool is_exit = false;//通过这个标志位通知子线程退出
 
void ThreadMain()
{
    cout << "begin sub thread main " << this_thread::get_id() << endl;
    for (int i = 0; i < 10; i++)
    {
        if (is_exit) break;
        cout << "in thread " << i << endl;
        this_thread::sleep_for(chrono::seconds(1));//1000ms
    }
    cout << "end sub thread main " << this_thread::get_id() << endl;
}
int main()
{
    {
        //thread th(ThreadMain);
        //th.detach();
        //主子线程分离,子线程就变成了在后台运行的线程,与主线程无关
        //坑:主线程退出后 子线程不一定退出,那造成一个什么现象,主线程退出之后,主线程的全局空间,
        //栈空间等全部都释放掉了,一旦子线程访问了这些空间,那么程序就会崩溃掉,在Windows中,写完程序
        //关闭的时候弹出一个错误窗口,那么多半是你还在运行的线程访问了静态成员变量或者全局变量,因为
        //变量都被销毁掉了,你还在访问它,那么就会出现错误。
    }
    {
        thread th(ThreadMain);
        this_thread::sleep_for(chrono::seconds(1));//1000ms
        is_exit = true; //通知子线程退出
        cout << "主线程阻塞,等待子线程退出" << endl;
        th.join(); //主线程阻塞,等待子线程退出
        cout << "子线程已经退出!" << endl;
    }
    //getchar();
    return 0;
}

 

        那么如何避免这样的情况呢?如果你用detach()函数分离了一个子线程,那么最后程序却崩溃了,那你的子线程就不要访问外部的变量了,就只访问线程函数内部的变量就行了,或者就是在主线程退出的时候通知一下我们,然后我们去退出,但是这种方式在实际问题中问题也比较多,所以大部分情况下不做detach了,而你又必须维系th这样的一个对象,不然你把对象删了,而子线程还在跑就会报错,那么我们就在整个程序析构的时候调用join函数就行了。


1.3 全局函数作为线程入口分析参数传递内存

#include <thread>
#include <iostream>
#include <string>
//Linux -lpthread
using namespace std;

class Para{
public:
    Para() { cout << "Create Para" << endl; }
    Para(const Para& p)
    {
        cout << "Copy Para" << endl;
        this->name = p.name;
    }
    ~Para() { cout << "Drop Para" << endl; }
    string name;
};
 
void ThreadMain(int p1, float p2, string str, Para p4){
    //延时100ms,确保f1被释放掉,因为此时主线程已经走到th.join();了
    //f1传过来后是拷贝,因此外部f1的销毁这里不受影响
    this_thread::sleep_for(100ms);
    cout << "ThreadMain " << p1 << " " << p2 << " " << str << " " << p4.name << endl;
}
 
int main(int argc, char* argv[]){
    thread th;
    //在栈空间中,一对大括号之后就会释放
    {
        //当这个f1释放之后,当调用thread创建线程这一行代码的时候,后面的参数都做了一份拷贝
        float f1 = 12.1f;
        Para p;
        p.name = "test Para class";
        //所有的参数做复制
        th = thread(ThreadMain, 101, f1, "test string para", p);
    }
    th.join();
    return 0;
}
#编译
g++ -o main main3.c -lpthread

这是Linux下的输出

 

        这是Windows下VSstudio的输出,有点搞不明白两次结果为什么不一样,对Windows的结果解释:大括号中间创建一次,传递到thread函数拷贝构造一次,再次调用回调函数时候再次拷贝构造一次。所以一共创建了三次。所以使用值传递的方式会有很大的拷贝开销,应该多多考虑使用引用传递和指针传递。


1.4 线程函数传递指针和引用

参数传递的一些坑,传递空间已经销毁,多线程共享访问一块空间,传递的指针变量的生命周期小于线程
在上节代码的基础上,添加如下代码:

地址传递:

这样的代码可以正常访问

这样就不能正常访问。这种问题的处理:对象p你就直接放在堆当中,或者使他成为静态的,另一种方式就是让参数的声明周期处于线程的生命周期之内就行。 

引用传递:

报错原因:thread函数的底层实现是用模板函数实现的,模板用了一个特殊函数,导致他不能识别这个是引用传递,它只能识别到这是一个普通参数,所以他就会去找普通参数对应的函数,又发现没有与之匹配的函数存在,所以需要在这里标识一下这是一个引用传递。 

在引用涉及到模板函数时都要去考虑,因为模板中一旦有引用的话都要标注一下他是一个引用的类型,也即ref(p)。引用同样会出现和指针类似的错误。

#include <thread>
#include <iostream>
#include <string>
//Linux -lpthread
using namespace std;
class Para
{
public:
    Para() { cout << "Create Para" << endl; }
    Para(const Para& p)
    {
        cout << "Copy Para" << endl;
        this->name = p.name;
    }
    ~Para() { cout << "Drop Para" << endl; }
    string name;
};
 
void ThreadMain(int p1, float p2, string str, Para p4)
{
    //延时100ms,确保f1被释放掉,因为此时主线程已经走到th.join();了
    this_thread::sleep_for(100ms);
    cout << "ThreadMain " << p1 << " " << p2 << " " << str << " " << p4.name << endl;
}
 
void ThreadMainPtr(Para* p)
{
    this_thread::sleep_for(100ms);
    cout << "ThreadMainPtr name = " << p->name << endl;
}
 
void ThreadMainRef(Para& p)
{
    this_thread::sleep_for(100ms);
    cout << "ThreadMainPtr name = " << p.name << endl;
}
int main(int argc, char* argv[])
{
/*
    {
        //传递引用
        Para p;
        p.name = "test ref";
        thread th(ThreadMainRef, ref(p));
        //thread th(ThreadMainRef, p);  这里会报错,原因:thread函数的底层实现是用模板函数实现的,模板用了一个特殊函数,导致他不能识别这个是引用传递,它只能识别到这是一个普通参数,所以他就会去找普通参数对应的函数,又发现没有与之匹配的函数存在,所以需要在这里标识一下这是一个引用传递。
        th.join();
    }
*/

/*
    {
        //传递线程指针
        Para p;
        p.name = "test ThreadMainPtr name";
        thread th(ThreadMainPtr, &p); //错误,线程访问的p空间会提前释放
        th.detach();
    }
    // Para类型的对象p放在栈空间,并且位于大括号内,所以会被释放
    getchar();
*/

/**/
    {
        //传递线程指针
        Para p;
        p.name = "test ThreadMainPtr name";
        thread th(ThreadMainPtr, &p);//可以正常访问
        th.join();
        getchar();
    }

    return 0;
}

 


1.5 成员函数作为线程入口(override,flush)

上一节涉及到参数的生命周期问题,假如需要传递很多的参数的话,怎么办呢?考虑使用对象来创建线程的话,那么对象的所有成员都可以作为我们的参数。

#include <thread>
#include <iostream>
#include <string>
//Linux -lpthread
using namespace std;

class MyThread
{
public:
    //入口线程函数
    void Main()
    {
        cout << "MyThread Main " << name << ":" << age << endl;
    }
    string name;
    int age = 100;
};
 
int main(int argc, char* argv[])
{
 
    MyThread myth;
    myth.name = "Test name age";
    myth.age = 20;
    thread th(&MyThread::Main, &myth);
    th.join();
 
    return 0;
}

这样我们就做出来一个以成员函数作为子线程的入口函数。
成员函数的指针:&MyThread::Main
成员函数不能直接访问,必须有对象的地址,就是你必须有一个this指针。当前对象的地址就是:&myth,&myth等价于this

线程基类的基本思路:

#include <thread>
#include <iostream>
#include <string>
//Linux -lpthread
using namespace std;

class XThread
{
public:
    virtual void Start()
    {
        is_exit_ = false;
        th_ = std::thread(&XThread::Main, this);
    }
    virtual void Stop()
    {
        is_exit_ = true;
        Wait();
    }
    virtual void Wait()
    {
        if (th_.joinable())
            th_.join();
    }
    bool is_exit() { return is_exit_; }
private:
    virtual void Main() = 0;
    std::thread th_;
    bool is_exit_ = false;
};

class TestXThread :public XThread
{
public:
    void Main() override
    {
        cout << "TestXThread Main begin" << endl;
        while (!is_exit())
        {
            this_thread::sleep_for(100ms);
            cout << "." << flush;    // 屏幕输出的话是先输出到输出序列的缓冲区,不会立刻显示,所以要加一个flush进行刷新,确保立即显示。
        }
        cout << endl << "TestXThread Main end" << endl;
    }
    string name;
};

int main(int argc, char* argv[])
{
    TestXThread testth;
    testth.name = "TestXThread name ";
    testth.Start();
    this_thread::sleep_for(3s);
    testth.Stop();
    return 0;
}

  

 如果派生类里面是想重载虚函数,就加上关键字override,这样编译器可以辅助检查是不是正确重载,如果没加这个关键字,也没什么严重的error,只是少了编译器检查的安全性,加上这个关键字表示确保你写的东西不会错,就不用等到编译阶段再报错了。


1.6 lambda临时函数作为线程入口函数

普通匿名函数作为线程的入口函数:

成员函数中的匿名函数作为线程的入口函数: 

#include <thread>
#include <iostream>
#include <string>
//Linux -lpthread
using namespace std;
// test lambda thread
class TestLambda
{
public:
    void Start()
    {
        thread th([this]() {cout << "name = " << name << endl; });
        th.join();
    }
 
    string name = "test lambda";
};
int main(int argc, char* argv[])
{
    thread th(
        [](int i) {cout << "test lmbda " << i << endl; },
        123
    );
    th.join();
    TestLambda test;
    test.Start();
 
    return 0;
}

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值