C++ 编程/线程
多任务处理 是一个过程,其中多个任务(也称为进程)共享公共处理资源,例如CPU。
具有单个 CPU 的计算机一次只能运行一个进程。通过运行,意味着在特定时间点,CPU 正在积极地为该进程执行指令。对于具有单个 CPU 的系统,使用调度可以实现多任务处理,通过该调度,处理器的执行时间由多个进程共享,从而允许每个进程推进其计算,看起来像是并行的。一个进程运行一段时间,另一个等待的进程就会获得执行机会。
将 CPU 从一项任务重新分配到另一项任务的行为称为上下文切换。当上下文切换频繁到足以产生并行性的错觉时,就会实现多任务处理。
即使在具有多个 CPU 的计算机(多处理器机器)上,多任务处理也允许运行比 CPU 数量更多的任务。
操作系统可以采用许多不同的调度策略,这些策略通常可以归类为以下几种:
- 在多道程序设计系统中,正在运行的任务会一直运行,直到执行需要等待外部事件(例如从磁带读取数据)的操作,或者直到计算机的调度程序强制将正在运行的任务从 CPU 中切换出去。多道程序设计系统旨在最大限度地利用 CPU。
- 在分时系统中,正在运行的任务需要自愿地或通过外部事件(例如硬件中断)释放 CPU。分时系统旨在允许多个程序看似同时执行。曾经用来定义这种行为的术语分时已经不再使用,取而代之的是多任务处理。
- 在实时系统中,一些等待的任务保证在外部事件发生时获得 CPU。实时系统旨在控制机械设备,例如工业机器人,这些设备需要及时处理。
多任务处理已经成功地集成到当前的操作系统中。如今使用的大多数计算机都支持一次运行多个进程。这是使用对称多处理器 (SMP)进行分布式计算以及使用多核或芯片多处理器 (CMP)进行计算的系统所必需的,在这种系统中,处理器已经从双核发展到四核,并且核的数量还会继续增加。每种技术都有其特定的局限性和适用性,但所有这些技术都共享一个共同的目标,即执行并发处理。
进程 是独立的执行单元,包含自己的状态信息,使用自己的地址空间,并且只通过进程间通信(IPC)机制相互交互。可以说,一个进程至少包含一个执行线程(不要与完整的线程结构混淆)。进程由托管操作系统在进程数据结构中管理。可以同时运行的进程的最大数量取决于操作系统以及该系统可用的资源。
子进程(也称为生成进程)是另一个进程(父进程)创建的进程,它继承了父进程的大多数属性,例如打开的文件。每个进程都可以创建许多子进程,但最多只有一个父进程;如果一个进程没有父进程,通常表明它是直接由内核创建的。
在UNIX中,子进程实际上是作为父进程的副本创建的(使用fork)。然后,子进程可以根据需要使用不同的程序覆盖自身(使用exec)。第一个进程称为init,它在启动时由内核启动,并且永不终止;其他没有父进程的进程可以启动以在用户空间中执行各种守护进程任务。进程最终没有父进程的另一种方式是,如果其父进程死亡,留下一个孤儿进程;但在这种情况下,它很快就会被 init 采用。
IPC 通常由操作系统管理。
大多数较新的操作系统都提供某种形式的内存保护。在 Unix 系统中,每个进程都会获得自己的虚拟地址空间,而系统反过来保证任何进程都无法访问另一个进程的内存区域。如果进程发生错误,只有该进程内存的内容会被破坏。
使用共享内存,可以解决在不同进程之间启用对共享数据的随机访问的需要。但是,将给定内存区域声明为可以被多个进程同时访问会引发控制和同步的需要,因为多个进程可能会尝试同时修改该内存区域。
直到最近,C++ 标准才包含对多线程的任何规范或内置支持。因此,线程必须使用特殊的线程库来实现,这些库通常依赖于平台,作为 C++ 标准的扩展。
一些流行的 C++ 线程库包括:
(此列表并非旨在完整。)
- Boost - 此包包含多个库,其中之一是线程(并发编程)。Boost 线程库的功能并不十分齐全,但它完整、可移植、健壮且符合 C++ 标准的风格。使用与 BSD 许可证类似的 Boost 许可证。
- 英特尔® 线程构建模块 (TBB) 提供了一种在 C++ 程序中表达并行性的丰富方法。该库可帮助您利用多核处理器性能,而无需成为线程专家。线程构建模块不仅仅是一个线程替换库。它代表了一种更高层次的任务级并行性,它抽象了平台细节和线程机制,以实现性能和可扩展性。它是一个在 GNU 通用公共许可证版本 2 (GPLv2) 下的开源项目,具有运行时例外。
- 英特尔® Cilk™ Plus (英特尔® Cilk™ Plus) 为 C 和 C++ 语言添加了简单的语言扩展来表达任务和数据并行性。这些语言扩展功能强大,但易于应用和使用在各种应用程序中。
- 自适应通信环境 (通常称为 ACE) - 另一个工具包,其中包含一个可移植的线程抽象,以及许多其他功能,所有这些都包含在一个库中。在非标准但非限制性许可证下发布的开源。
- Zthreads - 一个可移植的线程抽象库。该库功能丰富,只处理并发,并且在 MIT 许可证下开源。
当然,您可以从 C++ 访问完整的 POSIX 和 C 语言线程接口,以及 Windows 上的 API。那么为什么要费心在它之上使用库呢?
原因是诸如锁之类的资源是分配的,而 C++ 提供了抽象来简化这些资源的管理。例如,boost::scoped_lock<>
使用对象构造/析构来确保在离开对象的词法范围时解除互斥锁。这样的类在防止死锁、竞争条件和其他特定于线程程序的问题方面非常有用。此外,这些库使您能够编写跨平台的多线程代码,而使用平台特定函数则不能。
在任何情况下,当使用线程方法时,都规定您必须识别热点,即执行时间最长的代码段。为了确定实现最大性能的最佳机会,可以从自下而上和自上而下两种方法来确定可以并行运行的代码段。
在自下而上方法中,人们只关注代码中的热点。这需要对应用程序的调用堆栈进行深入分析,以确定可以并行运行并减少热点的代码段。在采用并发的热点部分中,仍然需要在调用堆栈中更高的地方移动该并发,以增加每个线程执行的粒度。
使用自上而下方法,重点关注应用程序的所有部分,以确定哪些计算可以在更高层次的抽象上编码为并行运行。降低抽象级别,直到整体性能提升足以达到必要的目标,其优点是实现速度快,代码可重用。这也是为所有计算实现最佳粒度级别的最佳方法。
- 线程与进程
线程和进程都是并行化应用程序的方法,其实现方式可能因不同的操作系统而异。进程始终只有一个执行线程,也称为主线程。一般来说,线程包含在进程内部(在进程的地址空间内),同一个进程的不同线程共享一些资源,而不同的进程则不共享。
原子性
[edit | edit source]原子性是指原子操作,这些操作是不可分割的和/或不可中断的。即使在单核上,也不能假设操作是原子的。在这方面,只有在使用汇编器时才能保证操作的原子性。因此,C++ 标准提供了一些保证,操作系统和外部库也提供了一些保证。
原子操作也可以看作是任何给定的操作集,可以将它们组合在一起,以便它们在系统的其余部分看来是一个只有一个结果的单一操作:成功或失败。这完全取决于抽象级别和底层保证。
所有现代处理器都提供基本原子基元,这些基元随后用于构建更复杂的原子对象。除了原子读写操作之外,大多数平台还提供一个原子读写更新操作,例如测试并设置或比较并交换,或一对操作,例如加载链接/存储条件,这些操作只有在原子地发生(即,没有中间冲突更新)时才会生效。这些可用于实现锁,这是多线程编程中的一种重要机制,允许在操作组之间强制执行不变性和原子性。
许多处理器,尤其是支持64 位浮点的32 位处理器,提供了一些非原子的读写操作:一个线程读取一个 64 位寄存器,而另一个线程正在写入它,可能会看到“之前”和“之后”值的组合,这种组合可能永远不会实际写入寄存器。此外,只有单个操作保证是原子的;任意执行一组读写操作的线程也将观察到“之前”和“之后”值的混合。显然,当这些影响可能发生时,不能依赖不变性。
如果没有处理已知的保证原子操作,则应依赖于抽象级别上的同步原语,即编码到抽象级别上的同步原语。
- 示例 - 一个进程
例如,假设一个进程在计算机上运行,在给定的内存位置中递增一个值。要在该内存位置递增值,
- 进程读取内存位置中的值;
- 进程将值加 1;
- 进程将新值写回内存位置。
- 示例 - 两个进程
现在,假设两个进程正在运行,它们递增同一个共享内存位置
- 第一个进程读取内存位置中的值;
- 第一个进程将值加 1;
但它还没有将新值写回内存位置,就被挂起了,第二个进程被允许运行
- 第二个进程读取内存位置中的值,与第一个进程读取的值相同;
- 第二个进程将值加 1;
- 第二个进程将新值写入内存位置。
第二个进程被挂起,第一个进程再次被允许运行
- 第一个进程将现在错误的值写入内存位置,没有意识到另一个进程已经更新了内存位置中的值。
这是一个简单的例子。在真实的系统中,操作可能更加复杂,引入的错误也极其微妙。例如,从内存中读取 64 位值实际上可能是对两个 32 位内存位置的两次顺序读取。如果一个进程只读取了前 32 位,并且在读取后 32 位之前内存中的值发生了改变,那么它将既没有原始值,也没有新值,而是一个混合的垃圾值。
此外,进程运行的特定顺序可能会改变结果,这使得这种错误难以检测和调试。
- 操作系统和可移植性
不仅要考虑底层硬件,还要考虑处理不同的操作系统 API。跨不同操作系统移植代码时,应该考虑提供哪些保证。在处理外部库时,也需要进行类似的考虑。
竞争条件
[edit | edit source]竞争条件(数据竞争,或简称为竞争)发生在从多个执行路径并发访问数据时。例如,当多个线程对同一个资源(例如文件或内存块)具有共享访问权,并且至少有一个访问是写入操作时,就会发生这种情况。这会导致它们相互干扰。
线程编程是围绕谓词和共享数据构建的。有必要识别所有可能的执行路径,并识别真正独立的计算。为了避免问题,最好在尽可能高的级别实现并发。
大多数竞争条件都是由于对线程运行顺序的错误假设造成的。在处理共享变量时,永远不要假设线程写入操作将在线程读取操作之前执行。如果您需要保证,您应该查看是否有同步原语可用,如果没有,您应该自己实现它们。
锁定
[edit | edit source]锁定 暂时阻止不可共享的资源被同时使用。锁定可以通过使用同步对象来实现。
线程最大的问题之一是锁定需要对数据和代码关系进行分析和理解。这会使软件开发变得复杂,尤其是在针对多个操作系统时。这使得多线程编程更像是一种艺术,而不是科学。
锁的数量(取决于同步对象)可能受操作系统限制。如果始终在同一临界区访问,则可以设置一个锁来保护多个资源。
临界区
[edit | edit source]临界区是指在代码执行并行化中被定义为至关重要的区域。该术语用于定义需要与程序中其他代码隔离执行的代码部分。
这是一个常见的基本概念。这些代码部分需要通过同步技术来保护,因为它们可能会产生竞争条件。
死锁
[edit | edit source]当发生锁定操作导致并发线程之间出现无休止的等待循环时,就会发生死锁。
同步
[edit | edit source]除了用于保证并行计算的正确执行外,同步是一种开销。尝试通过利用线程本地存储或使用专用内存位置来将其降至最低。
计算粒度
[edit | edit source]计算粒度是指在进行任何同步操作之前执行的计算量。同步之间的时间越长,计算的粒度越小。在处理并行需求时,这意味着更容易扩展到更多线程,并具有更低的开销成本。高粒度意味着使用线程的任何好处都可能因同步需求和一般线程开销而消失。
互斥锁
[edit | edit source]互斥锁是互斥的缩写。它依赖于操作系统(而不是 CPU)提供的同步机制。由于此系统对象在任何给定时间只能由一个线程拥有,因此互斥锁对象可以防止数据竞争,并允许线程之间进行线程安全的同步。通过调用其中一个锁定函数,线程获得互斥锁对象的拥有权,然后通过调用相应的解锁函数来放弃拥有权。互斥锁可以是递归的或非递归的,并且可以同时授予一个或多个线程拥有权。
信号量
[edit | edit source]信号量是一种让出同步对象,可以用于同步多个线程。这是最常用的同步方法。
自旋锁
[edit | edit source]自旋锁是忙等待同步对象,用作互斥锁的替代品。它们是使用机器相关的汇编指令(如测试和设置)进行线程间锁定的实现,其中线程只需在循环中等待(自旋),并重复检查锁是否可用(忙等待)。这就是为什么如果自旋锁锁定时间很短,它们会执行得更快。[1]它们永远不会在单 CPU 机器上使用。
线程
[edit | edit source]线程本身是代码结构,是程序的一部分,使它能够分叉(或拆分)自身到两个或多个同时(或伪同时)运行的任务。线程使用抢占式多任务。
线程是操作系统可以分配不同的处理器时间(调度)以供执行的基本单元(代码的最小部分)。这意味着实际上线程不是在任何单核系统上同时运行,而是在任何单核系统上按顺序运行。线程通常依赖于操作系统线程调度程序来抢占繁忙的线程并恢复另一个线程。
如今,线程不仅是大多数(如果不是所有)现代计算机、编程语言和操作系统支持的关键并发模型,而且本身也是硬件发展(例如对称多处理器)的核心,理解线程对于所有程序员来说都是必需的。
线程的执行顺序由操作系统的进程调度程序控制;它是非确定性的。程序员可用的唯一控制是为线程分配优先级,但永远不要假设特定的执行顺序。
用户界面线程
[edit | edit source]这种类型的区分是为了表明特定的线程实现了消息映射,以响应用户在与应用程序交互时生成的事件和消息。这在使用 Windows 平台(Win32 API)时尤其常见,因为它是实现消息泵的方式。
工作线程
[edit | edit source]这种区分用于指定不直接依赖或不是应用程序图形用户界面的一部分的线程,并与主执行线程并发运行。
线程本地存储 (TLS)
[edit | edit source]线程本地变量的驻留位置,一个线程专用的全局内存部分。每个线程(或纤程)都将收到自己的堆栈空间,位于不同的内存位置。这将包括保留的内存和最初提交的内存。线程退出时将被释放,但如果线程通过其他方式终止,则不会被释放。
由于进程中的所有线程共享相同的地址空间,因此静态或全局变量中的数据通常位于相同的内存位置,当由来自同一进程的线程引用时。软件必须考虑硬件缓存一致性。例如,在多处理器环境中,每个处理器都有一个本地缓存。如果不同处理器的线程修改驻留在同一缓存行的变量,这将使该缓存行无效,从而强制执行缓存更新,从而降低性能。这被称为错误共享。
这种类型的存储适用于存储临时数据或甚至部分结果的变量,因为将部分结果的必要同步压缩到尽可能少的实例中,并将同步开销降至最低。
线程同步
[edit | edit source]同步可以定义为几个步骤,第一步是进程锁定,其中进程由于发现受保护的资源被锁定而暂停执行,锁定会产生成本,尤其是如果锁定持续时间过长。
显然,如果任何同步机制被大量使用,就会降低性能。由于它们是一个昂贵的操作,在某些情况下,增加 TLS 的使用而不是仅仅依赖于共享数据结构将减少对同步的需求。
- 临界区
- 挂起和恢复
- 在对象上同步
- 协作式线程与抢占式线程
- 线程池
纤程
[edit | edit source]纤程是一种特别轻量级的执行线程。与线程类似,纤程共享地址空间。但是,纤程使用协作式多任务,纤程在执行时会让出自己以运行另一个纤程。
- 操作系统支持
与线程相比,协程需要更少的操作系统支持。它们可以在现代的 Unix 系统中使用 ucontext.h
库函数中的 getcontext
, setcontext
和 swapcontext
来实现,例如在 GNU Portable Threads 中。
在 Microsoft Windows 上,协程是使用 ConvertThreadToFiber
和 CreateFiber
函数创建的;当前挂起的协程可以在任何线程中恢复。类似于 线程本地存储,协程本地存储可以用于创建变量的唯一副本。
Symbian OS 在其 Active Scheduler 中使用了类似于协程的概念。一个 活动对象 (Symbian OS) 包含一个协程,当几个未完成的异步调用完成时,该协程由 Active Scheduler 执行。多个活动对象可以等待执行(基于优先级),并且每个活动对象都必须限制自己的执行时间。
利用并行性
[edit | edit source]大多数并行体系结构研究是在 1960 年代和 1970 年代进行的,为今天才逐渐引起人们普遍关注的问题提供了解决方案。随着并行编程需求的增长,主要是由于当今硬件的演进,我们作为程序员被要求实现编程模型,以简化处理旧线程模型的复杂过程,从而通过抽象问题来节省开发时间。
OpenMP
[edit | edit source]