(一)C++11 原生标准多线程:认识多线程

  • Post author:
  • Post category:其他


之所以称之为C++11原生标准多线程,因为在C++中C++11版本才加入的多线程。所谓原生也就是C++语言自带的,区别于其他库实现,比如POSIX,Boost,winAPI,QT【藐视很少有人提到QT的多线程,不过我就是因为要使用QT的多线程库,才认真学习C++11原生标准多线程的。】等的多线程实现。关于系统学习C++原生多线程编程的书,我推荐由Anthony Williams所著的《Cpp Concurrency In Action》,中文版由陈晓伟所译。Anthony Williams是C++11多线程标准起草着之一。


《C++ Concurrency in Action》中文版

在线阅读可以访问陈晓伟的gitbook,他是免费开源的。

关于并发

《C++ Concurrency in Action》一书中对并发通俗的解释:

最简单和最基本的并发,是指两个或更多独立的活动同时发生。

并发在生活中随处可见,我们可以一边走路一边说话,也可以两只手同时作不同的动作,还有我们每个人都过着相互独立的生活——当我在游泳的时候,你可以看球赛,等等。


为什么使用并发

:《C++ Concurrency in Action》中介绍的有点抽象,有点深奥。通俗的讲,就是

我们希望在相同的时间内做更多的事情:

把关注点(SOS)【我们要做的事情、任务】分离。还有就是

我们希望通过并发提高一下性能

。并发的目的始终会在这两种情况下发生。

C++中的并发和多线程

在C++11标准出来之前,使用C++开发并发程序只有依赖第三方库实现,或是系统API环境支持。这些都无法完美支持不依赖平台扩展的多线程开发。C++11带来了让人惊喜的变化,原生提供了标准化的多线程支持。有关C++多线程历史,推荐阅读《C++ Concurrency in Action》1.3.1节内容。

我使用的是QT开发,QT5系列版本的多线程支持依赖于C++11原生多线程标准。即将到来的QT6系列应该会全面支持C++17标准。所以有必要系统了解一下C++11的原生多线程技术。这对于了解QT多线程开发实现会有比较深刻的认识。当然,在QT环境,我们可以无视C++的原生多线程开发。因为即便是C++原生多线程和QT封装好的开发接口,亦或是其他类库,其大致不会有太大变化。而了解标准,也是敲开其他类库多线程开发的最好方法。

进程

进程(Process)是计算机中的程序关于某数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位,是操作系统结构的基础。在早期面向进程设计的计算机结构中,进程是程序的基本执行实体;在当代面向线程设计的计算机结构中,进程是线程的容器。程序是指令、数据及其组织形式的描述,进程是程序的实体。

通俗一点讲就是我们在电脑或是手机中运行的每一个程序,进程就是每一个程序的实体。既然进程是每个正在运行程序的实体,那每个程序至少就有一个独立的进程(当然某些程序也可能创建多个进程,比如下图的百度网盘),而进程之间是相互独立存在的。就像我们运行着:酷狗音乐、百度网盘、WPS等等,当我们听着歌,同时也在书写办公文件,这些同时进行的工作是独立进行的。而每一项正在进行的工作都是由单独的进程在执行。进程是由操作系统维护管理的,所以进程的资源开销相对于线程会大一点。这也是应用程序会更倾向于线程开发的一个原因。

如果我们打开windows的任务管理器我们将会看到:

线程


线程

(英语:


thread


)是操作系统能够进行运算调度的最小单位。它被包含在进程之中,是进程中的实际运作单位。一条线程指的是进程中一个单一顺序的控制流,一个进程中可以并发多个线程,每条线程并行执行不同的任务。在


Unix System V





SunOS


中也被称为轻量进程(


lightweight processes


),但轻量进程更多指内核线程(


kernel thread


),而把用户线程(


user thread


)称为线程。

线程是独立调度和分派的基本单位。线程可以为操作系统内核调度的内核线程,如


Win32


线程;由用户进程自行调度的用户线程,如


Linux


平台的


POSIX Thread


;或者由内核与用户进程进行混合调度,如


Windows 7


的线程。

同一进程中的多条线程将共享该进程中的全部系统资源,如虚拟地址空间,文件描述符和信号处理等等。但同一进程中的多个线程有各自的调用栈(


call stack


),自己的寄存器环境(


register context


),自己的线程本地存储(


thread-local storage


)。

一个程序可以有一个或多个进程,一个进程可以有很多线程。因为进程与进程是相互独立的,所以进程之间的通讯与信息共享会很繁琐而且难以实现。所以大多应用程序更多选择的是多线程。每条线程并行执行不同的任务。即便如此多线程开发也不是一件简单的事情,因为他关系到很多概念和陷阱。比如当年单例模式中发生的前期Bug,在使用了很久以后才发现。

在多核或多


CPU


,或支持


Hyper-threading





CPU


上使用多线程程序设计的好处是显而易见,即提高了程序的执行吞吐率。在单


CPU


单核的计算机上,使用多线程技术,也可以把进程中负责


I/O


处理、人机交互而常被阻塞的部分与密集计算的部分分开来执行,编写专门的


workhorse


线程执行密集计算,从而提高了程序的执行效率。

我对线程的理解就是:一个线程就是一段序列指令,为了完成某项单一的任务。所以,一个线程就是串行的执行任务。

多线程

多线程(英语:


multithreading


),是指从软件或者硬件上实现多个线程


并发


执行的技术。具有多线程能力的计算机因有硬件支持而能够在同一时间执行多于一个线程,进而提升整体处理性能。具有这种能力的系统包括对称多处理机、多核心处理器以及芯片级多处理(


Chip-level multithreading


)或同时多线程(


Simultaneous multithreading


)处理器。在一个程序中,这些独立运行的程序片段叫作“线程”(


Thread


),利用它编程的概念就叫作“多线程处理(


Multithreading


)”。


我的理解:多线程

就是多个线程并行执行任务。更通俗一点就是多个串行的任务,并行执行,同一时间,同时执行多个串行任务。


我的理解:并发

不一定是同时进行的。在单核CPU时代,更多的是一种假象。把所有的进程统一起来,按一定的算法顺序分片一个一个串行执行。并发更像是一种概念,指在同一时间开始的任务,但不一定是并行。而在现在多核心时代,并发往往伴随并行,但不一定完全是。我的理解是这样的:计算机上运行的程序(进程)往往比计算机的核心数量多,显而易见进程里包涵的线程数量会更多。这样一来,CPU不可能给我们提供这么多的并行计算。还是得回到单核时代的执行算法。这样一来,所谓的并行有时候也成为一种理想的概念。其宗旨和目的是不变得。

当然还有一种可能,在某些系统上,进程在某一个时间得到了CPU所有的核心执行权限,这时候如果线程还是比CPU核心多,那很可能依旧会有一定的串行执行。

有的资料说,多线程就是绝对的并行。从分析来看这是错误的。还要补充一点是,线程是被操作系统统一分配的执行权限。所以,编程人员顶多也就只能控制程序内部的线程控制。而系统对线程的安排,是程序员无能为力的。


线程安全:在实现线程之前需要考虑的问题

在单线程的执行环境中,无论线程是在运行还是因为某些信号中断而暂停,无论这个线程怎样去访问它所拥有的变量、资源,都会按照我们写的代码去安全的执行(线程安全的)。

如果在多线程中,我们可以想象,两个或两个以上的线程同时访问一个方法,而这个方法不会影响外部数据,只是影响函数内部的局部变量。它应该依旧是安全的(除非我们关心这几个进程调用的这个方法返回的数据之间所产生的数据有关联性)。问题是,如果我们访问的方法会影响全局数据,或是放在数据库里的持久数据。安全问题也就显而易见。我们称线程安全的对象或是方法是无态的。反之,多线程中的对象或是方法是有态的。【我对无态和有态的理解是:无态不会影响外部的数据,只会影响自己产生的局部变量数据。有态会影响外部的信息数据,与外部的对象或是属性有关联。】


线程安全问题举例

:我们的程序中需要一种特殊的类,这个类在整个程序中只能创建一个对象,如果已经实例化了一个对象,我们不允许再次创建(


设计模式之:单例模式


)。我们首先会使用


if object == nullptr


判断对象是否已经创建,如果已经创建,我们就不需要再次创建对象。这样看来一切都十分的和谐美好,完全是我们的理想结果。可理想终归是理想,当其中一个进程(我们称


A


进程)判断还没有创建对象时,这个类里的方法就会调用类的构造函数,开始创建对象。可,这时另外一个进程(我们称作


B


进程)刚好也需要这个类的对象实例,那么类的管理函数就会判断是否已经创建了对象实例,这时假如进程


A


还在创建的过程中,没有创建好对象实例。这时进程


B


就认为还没有创建对象实例,也开始创建对象实例。最后的结果是两个进程都创建了这个特殊类的对象实例,造成了不是我们需要的结果。这时不仅仅只是没按我们的预订结果执行,还会出现更无法预料的错误结果,还有可能会出现内存泄漏的问题。这就是线程安全的问题。这只是其中一种经典的单例模式多线程安全问题。如果是银行系统的存款、取款程序,依旧会产生线程安全问题。

《C++ Concurrency in Action》一书的附录有一个完整的ATM多线程例子。

如何实现线程:启动新线程

<thread> 头文件提供了管理和辨别线程的工具,并且提供函数,可让当前线程休眠。

std::thread类位于<thread> 头文件, std::thread 类用来管理线程的执行。其提供创建、启动或执行,也提供对线程的识别,以及提供其他函数用于管理线程的执行。所以对于C++多线程的管理我们只需要关注<thread> 头文件所提供的功能。


线程的入口:

我们知道线程是应用程序进程的最小执行单位。所以,一个应用程序,至少会有一个进程,而进程也至少会有一个线程存在。一个应用程序通过main()函数启动资源申请,通过操作系统的入口支持启动进程。而进程初始化完毕后,真正的实质化操作就交由线程区完成。由main()函数启动的线程,我们称之为


主线程





原始线程


。其他线程(如果我们需要新的线程)就需要我们去实现各自的入口函数(在C++标准中,当然是调用std::thread类)。


创建启动一个最简单的线程

:其实我们已经非常清楚一件事,线程就是一个任务。当任务

工作

执行完毕,这个任务(线程)也就执行完毕。也就是说,一个线程(任务)所要执行的工作一但执行完毕,这个线程(任务)也就自动结束。

创建一个新的线程在C++中就是构造std::thread 对象,当我们构造一个空std::thread 对象时,也就相当于我们创建了一个没有任何实质工作的空任务。当然,我们创建一个空任务,没有任何意义。所以我们必须告诉std::thread 对象(这个新的进程【任务】)需要实际作点什么。需要去作的工作有可能是一个函数方法,也可能是一个对象等等。

所以,我们的代码如下:

//我们提供一个函数交给一个新线程(新的任务)去执行
void do_some_work();
std::thread my_thread(do_some_work);

这是一个最单纯的新线程,我们之所以成为单纯。是因为我们提交给新线程(新任务)的工作是一个单纯的函数方法。

可是当我们要给这个新线程(新任务)派发的工作是一个对象的方法时,我们就必须告诉新线程(新任务)这个对象方法是谁的方法。也就是我们必须把对象也给传进取。



注意:


这一点对于刚接触std::thread 构造方法的朋友会有一点迷惑。很可能你会在一个类里创建了一个新进程,然后又调用了类里面的方法,让新线程去执行。我们可能会这样写代码。

//假如我们在一个Connection类里
//threadA是Connection类里的方法

std::thread t(&Connection::threadA);  //很多C++编辑器,不会给你发出警告,直到运行时才会发生错误。
t.join();


//刚使用的朋友,更可能会这样写
std::thread t(this->threadA);  //这时编辑器会给你亮出语法错误
t.join();



错误解释:

我们先看一下thread的声明原型:

//thread的声明原型
class thread 
{ 
public: 
    thread() noexcept; 
    thread( thread&& other ) noexcept; 
    template< class Function, class... Args > 
    explicit thread( Function&& f, Args&&... args ); 
    thread(const thread&) = delete; 
    ~thread(); 
    thread& operator=( thread&& other ) noexcept; 
    bool joinable() const noexcept; 
    std::thread::id get_id() const noexcept; 
    native_handle_type native_handle(); 
    void join(); 
    void detach(); 
    void swap( thread& other ) noexcept; 
    static unsigned int hardware_concurrency() noexcept; 
}; 

很显然,如果我们想创建一个新线程。让我们的任务(函数等)执行起来,而任务(函数)是对象的成员。我们必须实例化一个对象,或者我们要执行的任务(函数)是一个static静态成员函数。

如果成员函数是一个静态成员函数,那么一切都好办了。我们直接让新线程去去调用静态成员函数就可以了,不需要其他条件。这种情况,thread类的


thread( thread&& other ) noexcept;


构造函数起作用。

如果我们要调用的是一个普通成员函数,很显然我们必须让新线程知道,调用的成员函数是属于哪个对象的。我们可以看到thread类的构造函数原型里有一个模板成员函数:


template< class Function, class… Args > explicit thread( Function&& f, Args&&… args );


第一个模板参数依旧是我们需要传入的类成员函数,第二个模板参数的类型是一个类类型,很显然是需要我们传入对象实例。不太了解c++11新特性的朋友可能会注意到,这个成员函数有一个


explicit


声明。是的,explicit声明起作用的条件是,函数参数必须是一个,否则无效。而这里很显然参数个数已经大于1,这是怎么回事呢。原来explicit声明在大于一个参数时,如果在参数列表里,除了第一个参数以外的参数如果有默认值,那么explicit声明依旧有效。

如果熟悉 std::bind ,就应该不会对以上述传参的形式感到奇怪,因为 std::thread 构造函数和 std::bind 的操作都在标准库中定义好了,可以传递一个成员函数指针作为线程函数,并提供一个合适的对象指针作为第一个参数。——

《C++ Concurrency in Action》

另外在

《C++ Concurrency in Action》

一书的2.1 节:线程管理的基础中特别提到的一个问题,就是对象的运算符重载问题。这里假设我们对对象的运算符重载以后,以函数对象的方式传入任务以启动一个新线程。这时可能会出现语法错误解析。

原书实例:

class background_task
{
    public:
        void operator()() const
        {
            do_something();
            do_something_else();
        }
};

//background_task f;
//std::thread my_thread(f);

std::thread my_thread(background_task());


《C++ Concurrency in Action》

原书摘录:

有件事需要注意,当把函数对象传入到线程构造函数中时,需要避免“最令人头痛的语法解析”(C++’s most vexing parse, 中文简介)。如果你传递了一个临时变量,而不是一个命名的变量;C++编译器会将其解析为函数声明,而不是类型对象的定义。

这里相当与声明了一个名为my_thread的函数,这个函数带有一个参数(函数指针指向没有参数并返回background_task对象的函数),返回一个 std::thread 对象的函数,而非启动了一个线程。使用在前面命名函数对象的方式,或使用多组括号①,或使用新统一的初始化语法②,可以避免这个问题。

std::thread my_thread((background_task())); // 1
std::thread my_thread{background_task()}; // 2

使用lambda表达式也能避免这个问题。lambda表达式是C++11的一个新特性,它允许使用一个可以捕获局部变量的局部函数,可以避免传递参数。

这一问题的出现和对象运算符重载有关,当然只限这个案例。问题的根结就像书中所的那样,我们应该注意传入的是一个临时变量。



版权声明:本文为weixin_40583088原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。