优化多线程C++程序
用 std::async 替代 std::thread
如果你是启动一个线程去执行任务,那么应该用 std::async 替代 std::thread,因为你不用关心线程的关闭问题,它本身是异步的。
创建与核心数量一样多的可执行线程
C++ 提供了一个 std::thread::hardware_concurrency()
函数,它可以返回可用核心的数量。这个函数会计算由 hypervisor 分配给其他虚拟机的核心,以及因多线程同步而表现为两个或多个逻辑核心的核心的数量。通过这个函数,以后我们可以方便地将程序部署到包含更多(或少)核心的硬件上运行。
实现任务队列和线程池
解决不知道有多少个线程正在运行这个问题的方法是让线程更加明显:使用线程池(一种保持固定数量的永久线程的数据结构)和任务队列(一种存储待执行的计算的列表的数据结构),这些计算将由线程池中的线程负责执行。
在单独的线程中执行I/O
磁盘转速和网络连接距离等物理现实问题造成在程序请求数据和数据变为可用状态之间存在着延迟。因此, I/O 是适用并发的绝佳位置。另外一个典型的 I/O 问题是,程序在写数据之前或是读数据之后必须对它进行转换。例如,我们先从互联网上读取一个 XML 文件,接着解析它,从中提取程序所需信息。由于在对数据进行转换之前是无法直接使用它的,我们可以考虑将整个处理(包括读数据和解析数据)移动到一个单独的线程中。我们完全可以把这种I/O的计算工作也丢给线程池,我们称其为工作线程。
就拿网络库来举例,接收客户端的请求,此后根据请求的类型提高相应的服务,为了能够让服务器继续接收客户端的请求,就把提供服务的工作交给工作线程去做,等工作线程执行完之后再把结果返回给客户端。那么服务器就把接收客户请求和处理客户请求分离开,效率得到提升,这就是分工带来的销量提高。
没有同步的程序
同步和互斥虽然能够解决并发问题,但是对性能的影响还是相当明显的,下面介绍三种简单方式和一个困难方式。
(一)面向事件编程
在面向事件编程中,程序的控制流完全由事件驱动。框架通常在一个循环中不断地从事件队列中取出事件,然后调用注册的处理函数。整个过程是单线程的,没有多线程的同步问题。
也许你可以回想网络库中的 EventLoop。
(二)协程
协程比线程更加轻量级,并且C++20已经支持。与面向事件的程序相同,协程并非真正的多线程,因此只要它们不受多线程控制就不需要同步。
协程有两种。第一种有自己的栈,而且可以在执行途中的任何位置将控制转交给另外一个协程。第二种是向另外一个线程借栈,并且只能在它的顶层转交控制。
(三)消息传递
在消息传递程序中,控制线程从一个或多个输入源中接收输入,对输入进行转换后将它放到一个或多个输出槽中。相互连接的输出和输入组成了一幅具有良好定义的入口节点和出口节点的图。这些被实现了一个消息传递程序的各个阶段的线程所读写的元素可以是网络数据报、字符 I/O 流或是隐式队列中的数据结构。
比方说线程间通信或进程间通信的消息队列,分布式系统中的消息队列(Kafka)
(四)无锁编程
无锁编程是指无需互斥,允许多线程更新数据结构的编程实践。在无锁程序中,硬件同步的原子性操作取代了昂贵的互斥量。无锁数据结构远比由互斥量保护的传统容器要优秀,特别是当许多线程访问同一个容器时。
无锁数据结构很难讨论清楚。即使是著名专家也会就已公布算法的正确性进行争论。出于这个原因,我建议读者使用那些已经被广泛使用且有较好技术支持的无锁数据结构,而不要试图去构建自己的无锁数据结构。
移除启动和停止代码
一个程序能够启动足够多的线程来满足并发执行任务的需求,或是充分使用多核 CPU。不过,程序中有部分代码难以并发执行,那就是在 main() 得到控制权前执行的代码以及在main() 退出后执行的代码。
在 main() 开始执行前,所有具有静态存储期的变量都会被初始化。对于基本数据类型,初始化的性能开销是 0。链接器会让变量指向初始化数据。但是对于具有静态存储期的类类型,初始化过程会以标准所指定的特定顺序,在单独的线程中连续地调用各个变量的构造函数。这些开销单独看起来很小,但是加起来就会很大,导致大型程序在启动时会有几秒钟失去响应。
各位可以回想一下单例模式,如果你编写的是饿汉式单例,并且在你的系统中有非常多的单例,那么你的系统就会变的很慢,因为它需要提前被创建,而这些并不能被并发解决,谁让它在main() 得到控制权前执行的代码呢。
让同步更加高效
尽管我们前面讲互斥和同步会降低效率,但它确实是最常用的解决并发问题的手段。
减小临界区的范围
临界区是指获取互斥量和释放互斥量之间所包围的区域。在临界区的执行过程中,没有其他线程能够访问该互斥量所控制的共享变量,这当然是最恰当的。如果在临界区中并没有访问共享变量而是只做其他事情,那么其他线程就会白白浪费等待时间。
临界区的设计要点:
- 最小化临界区的范围:理想情况下,临界区应该尽可能小,只包含对共享资源的访问。这样做可以最大限度地减少阻塞其他线程的时间,提高并发执行的效率。如果在临界区内包含了不必要的操作,如复杂的计算或 I/O 操作,就会导致其他线程在等待时浪费宝贵的处理时间
- 避免长时间锁定:在临界区内执行耗时操作会增加锁定时间,导致其他线程长时间等待。这不仅降低了系统的并发性,还可能引发死锁或优先级反转等问题。因此,在设计临界区时,应将耗时操作移出临界区,确保只在必要时才进行锁定
限制并发线程的数量
如果线程数量比核心数量多,则只有一部分线程会被分配给核心,在某个时间点也只有一部分线程会实际运行。其他线程会在操作系统的“可运行”队列中等待被分配时间片。操作系统会因周期性的中断而醒来,决定运行哪个线程。与单独指令的执行速度相比,中断周期很长。因此,“可运行”队列中的线程可能会在操作系统为它分配核心之前等待许多毫秒。
竞争临界区的理想线程数量是两个。当只有两个线程时,就不存在“公平”或是“饥饿”问题,也不会发生下一节中将要介绍的惊群问题。
避免惊群
当有许多线程挂起在一个事件——例如只能服务一个线程的工作——上时就会发生所谓的惊群现象。当发生这个事件时,所有的线程都会变为可运行状态,但由于只有几个核心,因此只有几个线程能够立即运行。其中只有一个线程能够拿到互斥量继续进行工作,操作系统会将其他线程移动到可运行队列中,并最终逐个运行线程。每个线程都会发现发出的事件已经被其他某个线程服务了,只得继续挂起在这个事件上,虽然消耗了很多时间但线程处理却没有任何进展。
避免“惊群”问题的方法就是限制创建出的服务事件的线程的数量。两个线程可能比一个线程好,但是 100 个线程可能并不会更好。
避免锁护送
当大量线程同步,挂起在某个资源或是临界区上时会发生锁护送(lock convoy)。这会导致额外的阻塞,因为它们都会试图立即继续进行处理,但是每次却只有一个线程能够继续处理,仿佛是在护送锁一样。
一种简单的情况是接二连三地发生“惊群”现象。大量线程竞争一个互斥量,这样大量线程会挂起在该互斥量的操作系统信号上。当持有互斥量的线程释放它时,事件就会发生,所有挂起的线程都会变为可运行状态。第一个被分配到处理器的线程会再次锁住互斥量。所有的其他线程最终都会被分配到处理器,看到互斥量仍然被锁住了,然后再次挂起。这对程序的整体影响是操作系统虽然花费了很多时间重启线程,但大多数线程都无法继续处理。更糟糕的是,所有的线程都仍然是同步的。当下个线程释放互斥量时它们会立即醒来,然后如此往复循环。
一种更复杂的情况则是“惊群”线程都试图去获取第二个互斥量或执行读取文件等某种因设备的物理特性而成为性能瓶颈的操作。由于线程都是同步的,它们几乎会在同时试图去访问第二个资源。这些线程在同一个时间请求相同的资源会导致序列化,使性能下降。如果它们没有同步,那么它们可能都会继续处理。
减少竞争
(一)注意内存和 I/O 都是资源
并非所有的开发人员都注意到内存管理器是一种资源。在多线程系统中,内存管理器必须序列化对它的访问,否则它的数据结构会被破坏。当大量线程都试图分配动态变量(std::string 是一个特别的敌人)时,程序的性能可能会随着线程数量的增加出现断崖式下降。
文件 I/O 也是一种资源。磁盘驱动器一次只能读取一个地址。试图同时在多个文件上执行 I/O 操作会导致性能突然下降。
网络 I/O 也是一种资源。相对于数据传输,以太网连接器是一条相对较窄的管道。现代处理器甚至能够使 1000 兆带宽的以太网线满负荷传输,更别提 WiFi 连接了。
(二)复制资源
有时候,我们可以复制表,让每个线程都有一份非共享的副本,来移除多线程对于共享的 map 或是散列表等资源的竞争。尽管维护一个数据结构的两份副本会带来更多的工作,但与使用一种共享数据结构相比,它可能还会减少程序运行时间。
我们甚至能够复制磁盘驱动器、网卡等硬件资源来提高吞吐量。
(三)切割资源
有时候我们可以分割数据结构,让每个线程只访问它们所需的那部分数据,来避免多线程竞争同一个数据结构。
(四)细粒度锁
我们可以使用多个互斥量,而不是一个互斥量来锁住整个数据结构。例如,在散列表中,我们可以使用一个互斥量锁住散列表的骨干数组,防止其被修改(例如插入和删除元素),然后用另外一个互斥量锁住元素,防止它们被修改。这里, reader/writer 锁是一个不错的选择。要访问散列表的一条元素时,线程可以使用读锁锁住骨干数组,然后用一个读锁或写锁锁住元素。要插入或删除一条元素时,线程可以使用写锁锁住骨干数组。
(五)无锁结构
我们使用无锁散列表等无锁数据结构来摆脱对互斥的依赖。这是细粒度锁的终极形态。
(六)资源的调度
有些资源——例如磁盘驱动器——是无法被复制或分割的。但是我们可以调度磁盘活动,让它们不要同时发生,或是让访问磁盘相邻部分的活动同时发生。尽管操作系统会在细粒度级别调度读写操作,但是程序能够通过序列化读取配置文件等操作,避免它们同时发生。
不要在单核系统上繁忙等待
在单核处理器上,同步线程的唯一方法是调用操作系统的同步原语。繁忙等待太低效了。
事实上,繁忙等待会导致线程浪费整个时间片,因为除非在等待的线程放弃使用处理器,否则持有互斥量的线程是无法运行出临界区的。
不要永远等待
当一个线程无条件地等待一个事件时会如何呢?如果程序正常运行,可能什么事情都不会发生。但是如果用户试图停止程序,会如何呢?用户界面关闭了,但是程序不会停止,因为线程仍然在运行。如果 main() 尝试加入正在等待的线程,它会挂起。如果正在等待的线程被分离了, main() 会退出。接下来发生的事情取决于线程如何等待了。如果它正在等待一个标识位被设值,它会一直等待下去;如果它是在等待操作系统的事件,它会一直等待下去;如果它是在等待一个 C++ 对象,那么这取决于是否会有某个非阻塞线程删除该对象。这可能会导致正在等待的线程终止,也可能不会。
自己设计互斥量可能会低效
自己编写一个简单的类来作为互斥量,繁忙等待直到另一个线程更新原子性变量,这并不难。当没有激烈的线程竞争且临界区很短时,这样的类可能甚至比系统提供的互斥量更快。不过,操作系统提供的互斥量更加了解操作系统的奥秘,以及它调度任务以改善性能或是在该操作系统上避免优先级反转问题的方式。
限制生产者输出队列的长度
在生产者 / 消费者程序中,任何时候只要生产者比消费者快,数据就会在生产者和消费者之间累积。这种情况会产生许多问题,其中包括如下几个:
- 生产者竞争处理器、内存分配器和其他资源,进而降低了消费者的处理速度,使问题恶化。
- 生产者将会最终消费所有的系统内存资源,导致整个程序异常终止。
- 如果程序能够从异常中恢复过来,在重启之前它可能会需要处理队列中累积的所有数据,这将会增加程序的恢复时间。
解决方法是限制队列长度并在列队满员后阻塞生产者。队列的长度只需足够应对消费者性能的变化就可以了。多数情况下,队列其实只需能容纳若干元素即可。队列中的任何多余元素都只会导致生产者的运行遥遥领先,增加资源消耗,却对并发没有任何益处。