1. 当今的处理器有何不同
- 可将多核处理器视为一块包含多个处理器的芯片。这些处理器并行地运行,每个处理器都有独立的一级缓存,能够彼此独立地工作
- 处理器速度越快,应用程序的性能越高,这合乎逻辑。多核处理器对应用程序性能有何帮助呢?
- 每个内核都能并行地运行应用程序,但这并不一定能提高应用程序的速度,除非通过编程来利用这种新能力
- 前面介绍的C++应用程序都是单线程的,不能充分利用多核处理能力。这些应用程序运行在一个线程中,因此只能利用一个内核
- 如果应用程序依次执行所有的任务,操作系统(OS)分配给它的时间可能与队列中的其他应用程序一样多,而且它只占用处理器的一个内核
2. 如何更好地利用多个内核
- 关键在于创建多线程应用程序。所有的线程都并行地运行,操作系统可让它们在多个内核中运行
2.1 线程是什么
-
应用程序代码总是运行在线程中。线程是一个同步执行实体,其中的语句依次执行
-
可将 main() 的代码视为在应用程序的主线程中执行。在这个主线程中,可以创建并行运行的线程
-
如果应用程序除主线程外,还包含一个或多个并行运行的线程,则被称为多线程应用程序
-
创建线程的方式随操作系统而异,C++在头文件 thread 中提供了std::thread,它隐藏了与平台相关的细节
2.2 为何要编写多线程应用程
- 使用多线程技术的应用程序并行地执行特定任务的多个会话
- 假设有 10000 名用户在 Amazon 购物,您是其中的一员。Amazon 的 Web 服务器当然不会让其他 9999 位用户都等待,而是创建多个同时为用户服务的线程。如果该 Web 服务器运行在多核处理器或多处理器云上,这些线程将能够充分利用基础设施,向用户提供最佳的性能
- 另一个常见的多线程示例是,与用户交互(例如,通过进度条)的同时做其他工作的应用程序
- 这样的应用程序通常包含用户界面线程和工作线程,其中前者负责显示和更新用户界面以及接受用户输入,而后者在后台完成其任务。
- 磁盘碎片整理工具就是一个这样的应用程序。用户单击“开始”按钮后,将创建一个工作线程,负责扫描和整理磁盘碎片;与此同时,用户界面线程将显示进度,并提供取消碎片整理的选项。为了让用户界面线程显示进度,整理碎片的工作线程需要定期地提供进度;同样,为了让工作线程在用户撤销时停止工作,用户界面线程需要提供这种信息
- 多线程应用程序常常要求线程彼此通信,这样应用程序才能成为一个整体,而不是一系列互不关心、各自为政的线程
- 另外,顺序也很重要,您不希望用户界面线程在负责整理碎片的工作线程之前结束。
在有些情况下,一个线程需要等待另一个线程。例如,读取数据库的线程应等待写入数据库的线程结束(让一个线程等待另一个线程被称为线程同步)
2.3 线程如何交换数据
- 线程可共享变量,可访问全局数据。创建线程时,可给它提供一个指向共享对象(结构或类)的指针
- 线程将数据写入其他线程能够存取的内存单元,这让线程能够共享数据,从而彼此进行通信
- 工作线程知道进度,而用户界面线程需要获悉这种信息
- 工作线程定期地存储进度(用整数表示的百分比),而用户界面线程可使用它来显示进度
如果多个线程读写相同的内存单元,结果将如何呢?
- 有些线程开始读取数据时,其他线程可能还未结束写入操作,这将给数据的完整性带来威胁。这就是需要同步线程的原因所在
2.4 使用互斥量和信号量同步线程
- 线程是操作系统级实体,而用来同步线程的对象也是操作系统提供的
- 大多数操作系统都提供了信号量(semaphore)和互斥量(mutex),供您用来同步线程
- 互斥量(互斥同步对象)通常用于避免多个线程同时访问同一段代码。换句话说,互斥量指定了一段代码,其他线程要执行它,必须等待当前执行它的线程结束并释放该互斥量。接下来,下一个线程获取该互斥量,完成其工作,并释放该互斥量。从 C++11 起,C++ 通过类 std::mutex 提供了一种互斥量实现,这个类位于头文件 mutex 中
- 通过使用信号量,可指定多少个线程可同时执行某个代码段。只允许一个线程访问的信号量被称为二值信号量(binary semaphore)
2.5 多线程技术带来的问题
- 多线程应用程序面临的问题很多,下面是最常见的两个
- 竞争状态:多个线程试图写入同一项数据。哪个线程获胜?该对象处于什么状态?
- 死锁:两个线程彼此等待对方结束,导致它们都处于“等待”状态,而应用程序被挂起。妥善地同步可避免竞争状态。一般而言,线程被允许写入共享对象时,您必须格外小心,确保:
- 每次只能有一个线程写入
- 在当前执行写入的线程结束前,不允许其他线程读取该对象
- 通过确保任何情况下都不会有两个线程彼此等待,可避免死锁
- 可使用主线程同步工作线程,也可在线程之间分配任务时,确保工作负荷分配明确
- 可以让一个线程等待另一个线程,但绝不要同时让后者也等待前者
3. 编写杰出的C++代码
- 给变量指定(无论是对您还是其他人来说都)有意义的名称。值得多花点时间给变量取个好名。
- 对于int、float 等变量,务必进行初始化
- 务必将指针初始化为NULL 或有效的地址—如运算符new 返回的地址
- 使用数组时,绝不要跨越其边界。跨越数组边界被称为缓冲区溢出,可导致安全漏洞
- 不要使用字符串缓冲区(char*),也不要使用 strelen() 和 strcopy() 等函数。std::string 更安全,还提供了很多有用的方法,如获取长度、进行复制和附加的方法
- 仅当确定要包含的元素数时才使用静态数组。如果不确定,应使用 std::vector 等动态数组
- 声明和定义接受非 POD 类型作为输入的函数时,应考虑将参数声明为引用,以免调用函数时执行不必要的复制步骤
- 如果类包含原始指针成员,务必考虑如何在复制或赋值时管理内存资源所有权,即应考虑编写复制构造函数和赋值运算符
- 编写管理动态数组的实用类时,务必实现移动构造函数和移动赋值运算符,以改善性能。
- 务必正确地使用 const。理想情况下,get() 函数不应修改类成员,因此应将其声明为 const 函数。同样,除非要修改函数参数包含的值,否则应将其声明为 const 引用
- 不要使用原始指针,而应尽可能使用合适的智能指针
- 编写实用类时,务必花精力实现让它使用起来更容易的运算符
- 在有选择余地的情况下,务必使用模板而不是宏。模板不但是通用的,还是类型安全的
- 编写类时,如果其对象将存储在诸如 vector 和 list 等容器中,或者被用作映射中的键,务必实现运算符 < ,它将用作默认排序标准
- 如果您编写的 lambda 表达式很长,应考虑转而使用函数对象,即实现了 operator() 的类,因为函数对象可重用,且只有一个地方需要维护
- 绝不要认为运算符 new 肯定会成功。对于分配资源的代码,务必处理其可能引发的异常,即将其放在 try 块中,并编写相应的 catch() 块
- 绝不要在析构函数中引发异常