浅谈C++11中的多线程(一)

摘要

本篇文章围绕以下几个问题展开:

  1. 进程和线程的区别
  2. 何为并发?C++中如何解决并发问题?C++中多线程的基本操作
  3. 同步互斥原理以及多进程和多线程中实现同步互斥的两种方法
  4. 条件变量和原子操作

c++的多线程可以充分利用计算机资源,提高代码运行效率。在这里总结了一些多线程应用过程中的基本概念和用法。   

一,进程和线程的区别

  • 进程是一个应用程序被操作系统拉起来加载到内存之后从开始执行到执行结束的这样一个过程。简单来说,进程是程序(应用程序,可执行文件)的一次执行。比如双击打开一个桌面应用软件就是开启了一个进程。
  • 线程是进程中的一个实体,是被系统独立分配和调度的基本单位。也有说,线程是CPU可执行调度的最小单位。也就是说,进程本身并不能获取CPU时间,只有它的线程才可以。

引入线程之后,将传统进程的两个基本属性分开了,线程作为调度和分配的基本单位,进程作为独立分配资源的单位。

我对这句话的理解是:线程参与操作系统的调度,参与CPU的竞争,得到分配的时间片,获得处理机(CPU)运行。而进程负责获取操作系统分配的资源,如内存。

二,何为并发?

简单来说,并发指的是两个或多个独立的活动在同一时段内发生

与并发相近的另一个概念是并行。它们两者存在很大的差别,图示如下:

  • 并发:同一时间段内可以交替处理多个操作,强调同一时段内交替发生。

  • 并行:同一时刻内同时处理多个操作,强调同一时刻点同时发生。

在传统的单核CPU中,CPU通过极快的速度不停的切换不同应用程序的命令,而让我们看起来感觉计算机在同时执行很多个应用程序。比如,一边听歌,一边聊天,还能同时打游戏,我们误以为这是并发,其实只是一种伪并发的假象。

在出现多核处理器以后,使得并发真正的实现。C++中采用多线程实现并发。

三,C++11线程基本操作

一个多线程C++程序是什么样子的?它看上去和其他所有C++程序一样,通常是变量、类以及函数的组合。唯一真正的区别在于某些函数可以并发运行,所以你需要确保共享数据的并发访问是安全的。当然,为了并发地运行函数,必须使用特定的函数以及对象来管理各个线程。

C++11新标准多线程支持库

C++标准并没有提供对多进程并发的原生支持,所以C++的多进程并发要靠其他API(比如用CreateThread进行创建)
C++11可以通过多线程实现并发,这是一种比较底层、传统的实现方式。C++11引入了5个头文件来支持多线程编程,是<atomic>/<thread>/<mutex>/<condition_variable>/<future>

  • < thread > : 提供线程创建及管理的函数或类接口;
  • < mutex > : C++11 互斥量Mutex。在多线程环境中,有多个线程竞争同一个公共资源,就很容易引发线程安全的问题
  • < condition_variable > : 允许一定量的线程等待(可以定时)被另一线程唤醒,然后再继续执行;
  • < future > : 提供了一些工具来获取异步任务(即在单独的线程中启动的函数)的返回值,并捕捉其所抛出的异常
  • < atomic >:为细粒度的原子操作(不能被处理器拆分处理的操作)提供组件,允许无锁并发编程。

std::thread类成员函数:

  • get_id:获取线程ID,返回一个类型为std::thread::id的对象。
  • joinable:检查线程是否可被join。检查thread对象是否标识一个活动(active)的可行性线程。缺省构造的thread对象、已经完成join的thread对象、已经detach的thread对象都不是joinable。
  • join:通过join()函数关联并阻塞线程,等待该线程执行完毕后继续;
  • detach:通过detach()函数解除关联使线程可以与主线程并发执行,但若主线程执行完毕退出后,detach()接触关联的线程即便没有执行完毕,也将自动退出。
  • native_handle:该函数返回与std::thread具体实现相关的线程句柄。
  • swap:交换两个线程对象所代表的底层句柄。
  • operator:移动线程对象

有时候我们需要在线程执行代码里面对当前调用者线程进行操作,针对这种情况,C++11里面专门定义了一个命名空间this_thread,此命名空间也声明在<thread>头文件中,其中包括:

  • get_id()函数用来获取当前调用者线程的ID;
  • yield()函数可以用来将调用者线程跳出运行状态,重新交给操作系统进行调度,即当前线程放弃执行,操作系统调度另一线程继续执行;
  • sleep_until()函数是将线程休眠至某个指定的时刻(time point),该线程才被重新唤醒;
  • sleep_for()函数是将线程休眠某个指定的时间片(time span),该线程才被重新唤醒,不过由于线程调度等原因,实际休眠实际可能比sleep_duration所表示的时间片更长。

std::thread的关键总结:

  1. C++ 11中创建线程非常简单,使用std::thread类就可以,thread类定义于thread头文件,构造thread对象时传入一个可调用对象作为参数(如果可调用对象有参数,把参数同时传入),这样构造完成后,新的线程马上被创建,同时执行该可调用对象
  2. 用std::thread默认的构造函数构造的对象不关联任何线程;判断一个thread对象是否关联某个线程,使用joinable()接口,如果返回true,表明该对象关联着某个线程(即使该线程已经执行结束);
  3. "joinable"的对象析构前,必须调用join()接口等待线程结束,或者调用detach()接口解除与线程的关联,否则会抛异常;
  4. 正在执行的线程从关联的对象detach后会自主执行直至结束,对应的对象变成不关联任何线程的对象,joinable()将返回false;
  5. std::thread没有拷贝构造函数和拷贝赋值操作符,因此不支持复制操作(但是可以move),也就是说,没有两个 std::thread对象会表示同一执行线程;
  6.  容易知道,如下几种情况下,std::thread对象是不关联任何线程的(对这种对象调用join或detach接口会抛异常):

    默认构造的thread对象;

    被移动后的thread对象;

    detach 或 join 后的thread对象;

C++中多线程创建:

1️⃣简单使用

#include <iostream>
#include <thread>
using namespace std;

void f()
{
    cout<<"thread 1 is running"<<endl;
    this_thread::sleep_for(chrono::seconds(1));
}
int main()
{
    thread t1(f); //创建线程,一旦创建完毕,马上开始运行。
    t1.join();
}

这里创建传入的函数f,实际上其构造函数需要的是可调用(callable)类型,只要是有函数调用类型的实例都是可以的。所有除了传递函数外,还可以使用:

  • lambda表达式
for (int i = 0; i < 4; i++)
{
    thread t([i]{
        cout << i << endl;
    });
    t.detach();
}
  • 重载了()运算符的类的实例
#include <iostream>
#include <thread>
using namespace std;
class Task
{
public :
void operator()(int i)  //()重载
    {
        cout << i << endl;
    }
};

int main()
{
    for (int i = 0; i < 4; i++)
    {
        Task task;
        thread t(task, i);
        t.detach();
    }
}

当线程启动后一定要在和线程相关联的thread销毁前,确定以何种方式等待线程执行结束。C++11有两种方式来等待线程结束

  • detach方式,启动的线程自主在后台运行,当前的代码继续往下执行,不等待新线程结束。前面代码所使用的就是这种方式。因此执行的结果就多种多样了。

这就涉及到多线程编程最核心的问题了资源竞争。CPU有4核,可以同时执行4个线程是没有问题的。但是控制台(资源)却只有一个,同时只能有一个线程拥有这个唯一的控制台,将数字输出。

  • join方式,等待启动的线程完成,才会继续往下执行。假如前面的代码使用这种方式,其输出就会0,1,2,3,因为每次都是前一个线程输出完成了才会进行下一个循环,启动下一个新线程。

2️⃣带函数参数的线程

 当需要向线程函数传递参数时,直接在创建线程时,同时也把参数作为入参传递给线程函数。

注意当调用函数的参数为引用参数时,线程调用需要加上ref关键字表示引用。并且线程函数会改变引用的变量值。

void f1(int n)
{
    n++;
    cout<<"n = "<< n <<endl;
}

void f2(int &n)//引用参数
{
    n++;
    cout<<"n = "<<n<<endl;
}

int main()
{
    int n = 0;
    
    thread t1(f1, n);
    t1.join();
    cout<<"n = "<<n<<endl;
    
    thread t2(f2, ref(n));
    t2.join();
    cout<<"n = "<<n<<endl;

}

  运行结果为:

3️⃣转移线程的所有权

thread是可移动的(movable)的,但不可复制(copyable)。可以通过move来改变线程的所有权,灵活的决定线程在什么时候join或者detach。

void f2(int &n)
{
    n++;
    cout<<"n = "<<n<<endl;
}

int main()
{
    int n = 0;
    
    thread t3(f2, ref(n));
    thread t4(move(t3)); //此时t4正在运行f2(),t3不再是一个线程了。
    t4.join();

}

运行结果:

 将线程从t3转移给t4,这时候t3就不再拥有线程的所有权,调用t3.joint3.detach会出现异常,要使用t4来管理线程。这也就意味着thread可以作为函数的返回类型,或者作为参数传递给函数,能够更为方便的管理线程。

4️⃣线程暂停

如果让线程从外部暂停会引发很多并发问题,这也是为什么std::thread没有直接提供pause函数的原因。

如果线程在运行过程中,确实需要停顿,就可以用this_thread::sleep_for。

 this_thread::sleep_for(chrono::seconds(3)); //此处线程停顿3秒。

5️⃣获取当前线程号

线程的标识类型为std::thread::id,有两种方式获得到线程的id。

  • 通过thread的实例调用get_id()直接获取
  • 在当前线程上调用this_thread::get_id()获取
thread::id main_threadId =  this_thread::get_id();

小结:

 本结主要介绍了C++11引入的标准多线程库的一些基本操作。有以下内容:

  • 线程的创建
  • 线程的执行方式,join或者detach
  • 向线程函数传递参数,需要注意的是线程默认是以拷贝的方式传递参数的,当期望传入一个引用时,要使用std::ref进行转换
  • 线程是movable的,可以在函数内部或者外部进行传递
  • 线程在运行过程中,如果需要停顿,可以用this_thread::sleep_for实现。
  • 每个线程都一个标识,可以调用get_id获取。

 线程管理的示例代码

创建线程,并观察线程的并发执行与阻塞等待

#include <iostream>
#include <thread>
#include <chrono>

using namespace std;

void thread_function(int n)
{
    std::thread::id this_id = std::this_thread::get_id();            //获取线程ID

    for (int i = 0; i < 5; i++) {
        cout << "子线程 " << this_id << " 运行 : " << i + 1 << endl;
        std::this_thread::sleep_for(std::chrono::seconds(n));       //进程睡眠n秒
    }
}

class Thread_functor
{
public:
    // functor行为类似函数,C++中的仿函数是通过在类中重载()运算符实现,使你可以像使用函数一样来创建类的对象
    void operator()(int n)
    {
        std::thread::id this_id = std::this_thread::get_id();

        for (int i = 0; i < 5; i++) {
            cout << "子仿函数线程" << this_id << " 运行: " << i + 1 << endl;
            std::this_thread::sleep_for(std::chrono::seconds(n));   //进程睡眠n秒
        }
    }
};


int main()
{
    thread mythread1(thread_function, 1);      // 传递初始函数作为线程的参数
    if (mythread1.joinable())                  //判断是否可以成功使用join()或者detach(),返回true则可以,返回false则不可以
        mythread1.join();                     // 使用join()函数阻塞主线程直至子线程执行完毕

    Thread_functor thread_functor;             //函数对象实例化一个对象
    thread mythread2(thread_functor, 3);     // 传递初始函数作为线程的参数
    if (mythread2.joinable())
        mythread2.detach();                  // 使用detach()函数让子线程和主线程并行运行,主线程也不再等待子线程

    auto thread_lambda = [](int n) {            //lambda表达式格式:[捕获列表](参数列表)可变异常->返回类型{函数体}
        std::thread::id this_id = std::this_thread::get_id();
        for (int i = 0; i < 5; i++)
        {
            cout << "子lambda线程" << this_id << " 运行: " << i + 1 << endl;
            std::this_thread::sleep_for(std::chrono::seconds(n));   //进程睡眠n秒
        }
    };

    thread mythread3(thread_lambda, 4);     // 传递初始函数作为线程的参数
    if (mythread3.joinable())
        mythread3.join();                     // 使用join()函数阻塞主线程直至子线程执行完毕

    std::thread::id this_id = std::this_thread::get_id();
    for (int i = 0; i < 5; i++) {
        cout << "主线程" << this_id << " 运行: " << i + 1 << endl;
        std::this_thread::sleep_for(std::chrono::seconds(1));
    }

    getchar();
    return 0;
}

运行结果:

 上面的代码分别用三种函数对象创建了三个线程,其中第一个线程mythread1阻塞等待其执行完后继续往下执行,第二个线程mythread2不阻塞等待在后台与后面的第三个线程mythread3并发执行,第三个线程继续阻塞等待其完成后再继续往下执行主线程任务。为了便于观察并发过程,对三个线程均用了睡眠延时this_thread::sleep_for(duration)函数,且延时时间作为参数传递给该函数。

上面的示例假如多重复运行几次,有很大可能会出现某行与其他行交叠错乱的情况(如下图所示),为何会出现这种情况呢?

这就涉及到多线程资源竞争的问题了,即一个线程对某一资源(这里指显示终端)的访问还未完成,另一线程抢夺并访问了该资源,导致该资源数据混乱情况的出现。

解决方案详见下一篇文章:浅谈C++11中的多线程(二) - 唯有自己强大 - 博客园 (cnblogs.com)

 

相关博文:(2条消息) C++多线程并发(一)--- 线程创建与管理_流云-CSDN博客_c++多线程并发

  

posted @ 2021-07-14 10:24  唯有自己强大  阅读(9114)  评论(0编辑  收藏  举报