C++11 多线程编程

  1. 多线程是实现并发(并行)的一种手段。并行是指两个或多个独立的操作同时进行。注意这里是同时进行,区别于并发,在一个时间段内执行多个操作。在单核时代,多个线程是并发的,在一个时间段内轮流执行;在多核时代,多个线程可以实现真正的并行,在多核上真正独立的并行执行。例如现在常见的4核4线程可以并行4个线程;4核8线程则使用了超线程技术,把一个物理核模拟为2个逻辑核心,可以并行8个线程。

  2. 通常,要实现并发有两种方法:多进程和多线程。

  3. 多进程并发

    使用多进程并发是将一个应用程序划分为多个独立的进程(每个进程只有一个线程),这些独立的进程间可以互相通信,共同完成任务。由于操作系统对进程提供了大量的保护机制,以避免一个进程修改了另一个进程的数据,使用多进程比多线程更容易写出安全的代码。但这也造就了多进程并发的两个缺点:

    在进程间的通信,无论是使用信号、套接字,还是文件、管道等方式,其使用要么比较复杂,要么就是速度较慢或者两者兼而有之。

    运行多个进程的开销很大,操作系统要分配很多的资源来对这些进程进行管理。

    由于多个进程并发完成同一个任务时,不可避免的是:操作同一个数据和进程间的相互通信,上述的两个缺点也就决定了多进程的并发不是一个好的选择。

  4. 多线程并发

    多线程并发指的是在同一个进程中执行多个线程。线程是轻量级的进程,每个线程可以独立的运行不同的指令序列,但是线程不独立的拥有资源,依赖于创建它的进程而存在。也就是说,同一进程中的多个线程共享相同的地址空间,可以访问进程中的大部分数据,指针和引用可以在线程间进行传递。这样,同一进程内的多个线程能够很方便的进行数据共享以及通信,也就比进程更适用于并发操作。由于缺少操作系统提供的保护机制,在多线程共享数据及通信时,就需要程序员做更多的工作以保证对共享数据段的操作是以预想的操作顺序进行的,并且要极力避免死锁(deadlock)

  5. C++11的标准库中提供了多线程库,使用时需要#include <thread>头文件,该头文件主要包含了对线程的管理类std::thread以及其他管理线程相关的类。

  6. 共享数据的管理以及线程间的通信,是多线程编程的两大核心。

  7. C++ 11的线程库启动一个线程是非常简单的,只需要创建一个std::thread对象,就会启动一个线程,并使用该std::thread对象来管理该线程。这里创建std::thread传入的函数,实际上其构造函数需要的是可调用(callable)类型,只要是有函数调用类型的实例都是可以的。所有除了传递函数外,还可以使用: Lambda表达式、重载了()运算符的类的实例。

  8. 把函数对象传入std::thread的构造函数时,要注意一个C++的语法解析错误(C++’s most vexing parse)。向std::thread的构造函数中传入的是一个临时变量,而不是命名变量就会出现语法解析错误。 std::thread t(Task());这里相当于声明了一个函数t,其返回类型为thread,而不是启动了一个新的线程。可以使用新的初始化语法避免这种情况std::thread t{Task()};

  9. 当线程启动后,一定要在和线程相关联的thread销毁前,确定以何种方式等待线程执行结束。C++11有两种方式来等待线程结束:
    • detach方式,启动的线程自主在后台运行,当前的代码继续往下执行,不等待新线程结束。
    • join方式,等待启动的线程完成,才会继续往下执行。
  10. 无论在何种情形,一定要在thread销毁前,调用t.join或者t.detach,来决定线程以何种方式运行。当使用join方式时,会阻塞当前代码,等待线程完成退出后,才会继续向下执行;而使用detach方式则不会对当前代码造成影响,当前代码继续向下执行,创建的新线程同时并发执行,这时候需要特别注意:创建的新线程对当前作用域的变量的使用,创建新线程的作用域结束后,有可能线程仍然在执行,这时局部变量随着作用域的完成都已销毁,如果线程继续使用局部变量的引用或者指针,会出现意想不到的错误,并且这种错误很难排查。

  11. 在以detach的方式执行线程时,要将线程访问的局部数据复制到线程的空间(使用值传递),一定要确保线程没有使用局部变量的引用或者指针,除非你能肯定该线程会在局部作用域结束前执行结束。当然,使用join方式的话就不会出现这种问题,它会在作用域结束前完成退出。

  12. 当决定以detach方式让线程在后台运行时,可以在创建thread的实例后立即调用detach,这样线程就会后thread的实例分离,即使出现了异常thread的实例被销毁,仍然能保证线程在后台运行。但线程以join方式运行时,需要在主线程的合适位置调用join方法,如果调用join前出现了异常,thread被销毁,线程就会被异常所终结。为了避免异常将线程终结,或者由于某些原因,例如线程访问了局部变量,就要保证线程一定要在函数退出前完成,就要保证要在函数退出前调用join

  13. 解决这个问题的一种比较好的方法是资源获取即初始化(RAII,Resource Acquisition Is Initialization),该方法提供一个类,在析构函数中调用join

  14. 向线程调用的函数传递参数也是很简单的,只需要在构造thread的实例时,依次传入即可。如果在线程中使用引用来更新对象时,就需要注意了。默认的是将对象拷贝到线程空间,其引用的是拷贝的线程空间的对象,而不是初始希望改变的对象。、

  15. 线程启动的函数形参引用的实际上是局部变量node的一个拷贝,而不是node本身。在将对象传入线程的时候,调用std::ref,将node的引用传入线程,而不是一个拷贝。thread t(func,std::ref(node));也可以使用类的成员函数作为线程函数,第二个参数为类对象的引用,第三个参数为成员函数的第一个参数,以此类推。

  16. thread是可移动的(movable)的,但不可复制(copyable)。可以通过move来改变线程的所有权,灵活的决定线程在什么时候join或者detach。thread t1(f1);thread t3(move(t1));将线程从t1转移给t3,这时候t1就不再拥有线程的所有权,调用t1.joint1.detach会出现异常,要使用t3来管理线程。这也就意味着thread可以作为函数的返回类型,或者作为参数传递给函数,能够更为方便的管理线程。

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

    • 通过thread的实例调用get_id()直接获取
    • 在当前线程上调用this_thread::get_id()获取
  18. 线程存在于进程之中,进程内所有全局资源对于内部每个线程都是可见的。 进程内典型全局资源如下: 1)代码区:这意味着当前进程空间内所有的可见的函数代码,对于每个线程来说,也是可见的 2)静态存储区:全局变量,静态空间 3)动态存储区:堆空间 线程内典型的局部资源: 1)本地栈空间:存放本线程的函数调用栈,函数内部的局部变量等 2)部分寄存器变量:线程下一步要执行代码的指针偏移量

  19. 一个进程发起后,会首先生成一个缺省的线程,通常称这个线程为主线程,C/C++程序中,主线程就是通过main函数进入的线程,由主线程衍生的线程成为从线程,从线程也可以有自己的入口函数,相当于主线程的main函数,这个函数由用户指定。通过thread构造函数中传入函数指针实现,在指定线程入口函数时,也可以指定入口函数的参数。就像main函数有固定的格式要求一样,线程的入口函数也可以有固定的格式要求,参数通常都是void类型,返回类型根据协议的不同也不同,pthread中是void,winapi中是unsigned int,而且都是全局函数。

  20. 无论在windows中还是Posix中,主线程和子线程的默认关系是:无论子线程执行完毕与否,一旦主线程执行完毕退出,所有子线程执行都会终止。这时整个进程结束或僵死,部分线程保持一种终止执行但还未销毁的状态,而进程必须在其所有线程销毁后销毁,这时进程处于僵死状态。线程函数执行完毕退出,或以其他非常方式终止,线程进入终止态,但是为线程分配的系统资源不一定释放,可能在系统重启之前,一直都不能释放,终止态的线程,仍旧作为一个线程实体存在于操作系统中,什么时候销毁,取决于线程属性。

  21. 主线程和子线程通常定义以下两种关系: 1、可会合(joinable):这种关系下,主线程需要明确执行等待操作,在子线程结束后,主线程的等待操作执行完毕,子线程和主线程会合,这时主线程继续执行等待操作之后的下一步操作。主线程必须会合可会合的子线程。在主线程的线程函数内部调用子线程对象的wait函数实现,即使子线程能够在主线程之前执行完毕,进入终止态,也必须执行会合操作,否则,系统永远不会主动销毁线程,分配给该线程的系统资源也永远不会释放。 2、相分离(detached):表示子线程无需和主线程会合,也就是相分离的,这种情况下,子线程一旦进入终止状态,这种方式常用在线程数较多的情况下,有时让主线程逐个等待子线程结束,或者让主线程安排每个子线程结束的等待顺序,是很困难或不可能的,所以在并发子线程较多的情况下,这种方式也会经常使用。

  22. 在任何一个时间点上,线程是可结合(joinable)或者是可分离的(detached),一个可结合的线程能够被其他线程回收资源和杀死,在被其他线程回收之前,它的存储器资源如栈,是不释放的,相反,一个分离的线程是不能被其他线程回收或杀死的,它的存储器资源在它终止时由系统自动释放。

  23. 线程的分离状态决定一个线程以什么样的方式来终止自己,在默认的情况下,线程是非分离状态的,这种情况下,原有的线程等待创建的线程结束,只有当pthread_join函数返回时,创建的线程才算终止,释放自己占用的系统资源,而分离线程没有被其他的线程所等待,自己运行结束了,线程也就终止了,马上释放系统资源。

  24. C++变量存储期 程序中的所有对象拥有下列存储期之一: 自动存储期 对象的存储在外围代码块开始时分配,而在结束时解分配。 除了声明为 static 、 extern 或 thread_local 的所有局部对象拥有此存储期。 静态存储期 对象的存储在程序开始时分配,而在程序结束时解分配。只存在对象的一个实例。 所有声明于命名空间作用域(包含全局命名空间)的对象,加上声明带有 static 或 extern 的对象拥有此存储期。 线程存储期 对象的存储在线程开始时分配,而在线程结束时解分配。 每个线程拥有其自身的对象实例。唯有声明为 thread_local 的对象拥有此存储期。 thread_local 能与 static 或 extern 一同出现,以调整链接。(C++11 起) 动态存储期 通过使用动态内存分配函数,由请求分配和解分配对象的存储。

  25. C/C++存储类指定符

1 auto 自动存储期。(C++11 前) auto 不再是存储类指定符,它被用于指示类型推导。(C++11 起) 2 register 自动存储期。亦提示编译器将此对象置于处理器的寄存器。(C++17 前). 弃用(C++17起) 3 static 静态或线程存储期和内部链接。 4 extern 静态或线程存储期和外部链接。 5 thread_local 线程存储期。(C++11 起) 6 mutable 关键词 mutable 在 C++ 文法中是存储类指定符,尽管它不影响存储期或链接。

    • 在同一个声明中不能使用多个指定符,但thread_local除外,它可与static或extern结合使用
    • extern 关键词亦可用于指定语言链接和显式模板实例化声明,但在这些情况中不是存储类指定符。
    • 关键字register最初是由C语言引入的,建议编译器使用CPU寄存器来存储自动变量,只在提高访问变量的速度。C++11中只是显示指出变量是自动的,与原来的auto用途完全相同(未删除是为了老代码兼容)。
    • mutable指出即使结构(或类)变量为const,其某个成员也可以被修改。
  1. 可以使用thread_local声明的变量包括:
  • 命名空间下的全局变量
  • 类的static成员变量
  • 本地变量
  1. std::this_thread::yield() 是让线程让渡出自己的CPU时间片(给其他线程使用) sleep_for() 是线程根据某种需要, 需要等待若干时间.