在前面的xue中,我们探讨了进程的概念。传统进程模型仅包含单一控制线程,作为进程内的基本执行单元。然而,现代操作系统普遍支持多线程进程架构,允许单个进程内包含多个执行线程,这些线程可以并发执行,共享进程的地址空间和系统资源。特别是在多核处理器架构广泛应用的今天,多线程编程已成为充分利用硬件并行计算能力的关键技术。
因此这节课我们将系统学习多线程编程的核心概念、实现机制以及面临的挑战与解决方案。我们还将探讨现代并发编程范式,包括隐式线程管理技术,这些技术将线程的创建、调度和生命周期管理交由编译器和运行时系统处理,使开发者能够专注于识别可并行化的计算任务,而将底层线程管理细节委托给语言运行时和框架自动处理。
线程是CPU使用的基本单位,它包含线程ID、程序计数器(PC)、寄存器组和栈。它与属于同一进程的其他线程共享代码段、数据段和其他操作系统资源,如打开的文件和信号。 传统的进程只有一个控制线程。如果一个进程有多个控制线程,它可以同时执行多个任务。

现代软件应用程序普遍采用多线程架构,将应用程序实现为包含多个控制线程的独立进程。多线程编程的核心价值在于能够充分利用现代处理器的并行计算能力,显著提升应用程序的执行效率和响应性能。
以批量图像处理场景为例,单线程执行模型需要顺序处理每张图像,计算资源利用率低。多线程模型则可以将不同的图像处理任务分配给不同的执行线程,实现真正的并行计算,大幅提升处理吞吐量。在实际应用中,照片处理软件可能为每张图像分配独立的线程执行缩略图生成任务;网页浏览器可能使用一个线程负责渲染界面元素,同时使用另一个线程执行网络数据检索操作;文字处理器可能采用多个线程分别处理图形渲染、用户输入响应和后台拼写检查等任务。这种并发执行模式使得应用程序能够在执行耗时操作的同时保持对用户交互的响应能力。
多线程编程为应用程序带来四个核心优势:
多线程架构类似于多服务员的餐厅运营模式。每个服务员(线程)可以并发处理不同的顾客请求(任务),共享餐厅的公共资源(进程内存空间),相比为每个任务创建独立的餐厅经理(进程),这种模式在资源利用和响应效率方面都更加经济高效。

在计算机设计的历史早期,为了满足更多计算性能的需求,单CPU系统演变为多CPU系统。后来,系统设计的类似趋势是在单个处理芯片上放置多个计算核心,每个核心对操作系统来说都像一个独立的CPU。 我们称这样的系统为多核系统,多线程编程提供了一种更有效地使用这些多个计算核心和改进并发的机制。
考虑一个有四个线程的应用程序。在单核系统上,并发仅仅意味着线程的执行将在时间上交错进行,因为处理核心一次只能执行一个线程。 然而,在多核系统上,并发意味着一些线程可以并行运行,因为系统可以为每个核心分配一个单独的线程。
注意并发和并行之间的区别。并发系统通过允许所有任务取得进展来支持多个任务。相比之下,并行系统可以同时执行多个任务。因此,没有并行性也可能有并发性。
在多处理器和多核架构出现之前,大多数计算机系统只有一个处理器,CPU调度器被设计为通过快速切换进程来提供并行性的假象,从而允许每个进程取得进展。这样的进程是并发运行的,但不是并行的。
多核系统的广泛应用带来了高效利用多个计算核心的挑战,主要包括:合理识别和划分可并发的独立任务(任务识别和数据分割),为各处理核心均衡分配计算负载(负载均衡),以及有效识别和处理任务间的数据依赖,利用同步机制避免竞态条件和数据不一致。 此外,并发程序由于执行路径多样且不可预测,测试与调试也比单线程程序更加困难。
由于这些挑战,许多软件开发人员认为多核系统的出现将需要在未来采用全新的软件系统设计方法。同样,许多计算机科学教育工作者认为软件开发必须更加重视并行编程。
阿姆达尔定律是一个公式,它识别了向具有串行(非并行)和并行组件的应用程序添加额外计算核心的潜在性能增益。如果S是必须在具有N个处理核心的系统上串行执行的应用程序部分,公式如下:
举例说明,假设一个应用程序中75%的代码可以并行执行,剩余25%必须串行执行。在双核处理器系统上,理论加速比约为1.6倍;在四核处理器系统上,理论加速比提升至约2.28倍。
阿姆达尔定律揭示了一个重要特性:当处理器数量趋于无穷大时,程序的加速比不会无限增长,而是收敛于1/S的极限值。例如,如果应用程序中50%的代码必须串行执行(S=0.5),无论增加多少处理器,理论最大加速比仅为2倍。这一特性表明,应用程序中不可并行化的串行部分构成了性能提升的根本性瓶颈,严重限制了多核系统带来的性能增益。
并行化策略主要分为两种基本模式:数据并行和任务并行。数据并行模式将数据集合划分为多个子集,每个处理核心对各自的数据子集执行相同的计算操作。以数组求和为例,单核系统需要顺序遍历整个数组,而多核系统可以将数组划分为多个片段,不同核心并行计算各自片段的局部和,最后将局部结果聚合得到全局和,从而显著提升计算效率。
任务并行模式则是指不同的处理核心执行不同的计算任务,这些任务可能操作相同的数据集,也可能处理不同的数据。继续以数组处理为例,一个线程可能负责计算数组元素的和,另一个线程同时计算数组的最大值,两个线程并发执行各自的计算任务。数据并行可以概括为“数据分割、操作统一”,而任务并行则是“任务分工、操作各异”。这两种并行化模式并非互斥,许多实际应用程序会同时采用数据并行和任务并行策略,以最大化并行计算收益。

到目前为止,我们的讨论一直以通用方式处理线程。然而,线程支持可以在用户级别提供(用于用户线程),也可以由内核提供(用于内核线程)。 用户线程在内核之上得到支持,无需内核支持即可管理,而内核线程由操作系统直接支持和管理。几乎所有现代操作系统——包括Windows、Linux和macOS——都支持内核线程。
最终,用户线程和内核线程之间必须存在关系。在本节中,我们看看建立这种关系的三种常见方式:多对一模型、一对一模型和多对多模型。
多对一模型将多个用户级线程映射到一个内核线程。线程管理由用户空间中的线程库完成,因此效率很高。但是,如果线程进行阻塞系统调用,整个进程将被阻塞。此外,因为一次只有一个线程可以访问内核,多个线程无法在多核系统上并行运行。
绿线程——Solaris系统上可用的线程库,在Java早期版本中被采用——使用了多对一模型。然而,很少有系统继续使用该模型,因为它无法利用多个处理核心的优势,而多核现在已成为大多数计算机系统的标准。
一对一模型将每个用户线程映射到一个内核线程。它通过允许另一个线程在线程进行阻塞系统调用时运行,提供了比多对一模型更多的并发性。它还允许多个线程在多处理器上并行运行。这个模型的唯一缺点是创建用户线程需要创建相应的内核线程,大量的内核线程可能会给系统性能带来负担。Linux以及Windows操作系统家族实现了一对一模型。
多对多模型将多个用户线程映射到多个内核线程,映射关系既非一一对应,也非全部映射到单一内核线程。例如,十个用户线程可以灵活映射到三个或五个内核线程,内核线程的数量可以根据系统处理核心数量或应用程序需求动态调整。
多对多模型结合了前两种模型的优势。多对一模型虽然支持创建大量用户线程,但由于底层仅有一个内核线程,所有用户线程必须串行执行,无法实现真正的并行计算。一对一模型虽然每个用户线程都能并行执行,但大量内核线程会带来显著的系统开销。多对多模型在两者之间取得平衡,允许创建任意数量的用户线程,同时根据实际需求灵活分配内核线程数量,既能实现并行执行,又不会造成过度的系统负担。此外,当某个线程因I/O操作阻塞时,内核可以将其他线程调度到可用的内核线程上执行,避免整个进程被阻塞。
多对多模型存在一个变体,称为两级模型,它允许将特定用户线程绑定到特定内核线程,形成一对一映射关系,而其他用户线程仍采用多对多映射策略。
尽管多对多模型在理论上具有灵活性优势,其实现复杂度较高。现代操作系统普遍采用一对一模型,因为多核处理器的普及使得内核线程数量增加带来的开销可以接受。然而,现代并发编程库和任务调度框架仍然采用多对多映射的思想,在应用层实现任务的灵活调度和线程分配,使开发者无需关注底层线程管理的具体细节。
多对一模型类似于单一管理者管理多个服务人员,一对一模型类似于为每个服务人员分配专属管理者,而多对多模型则类似于多个服务人员共享若干管理者。每种线程映射模型都有其特定的优势和局限性,选择何种模型取决于应用程序的具体需求和系统资源约束。
线程库为开发者提供了线程创建和管理的应用程序编程接口(API),封装了底层线程操作的复杂性。线程库的实现方式主要分为两种类型。
用户级线程库完全在用户空间实现,无需操作系统内核的直接支持。调用线程库函数实际上是在用户空间内执行普通函数调用,避免了用户态与内核态之间的上下文切换,执行效率较高。然而,用户级线程库的局限性在于,当某个线程执行阻塞系统调用时,由于所有用户线程映射到单一内核线程,整个进程将被阻塞,无法实现真正的并行执行。
内核级线程库由操作系统内核直接支持和管理,库的实现代码位于内核空间。调用线程库API会触发系统调用,由内核负责线程的创建、调度和生命周期管理。内核级线程库的优势在于,当某个线程因阻塞操作暂停时,内核可以将其他线程调度到可用的处理核心上继续执行,实现真正的并行计算。其代价是每次线程操作都需要进行用户态与内核态之间的切换,带来一定的系统开销。
当前主流的线程库主要包括三种:POSIX Pthreads、Windows线程库和Java线程API。Pthreads是POSIX标准(IEEE 1003.1c)定义的线程接口规范,在类Unix系统(如Linux、macOS)上广泛使用。Pthreads规范可以基于用户级线程库实现,也可以基于内核级线程库实现,具体实现方式由操作系统设计者决定。Windows线程库是Windows操作系统原生提供的线程管理接口,由操作系统内核直接管理,采用内核级线程实现。Java线程API是Java语言提供的线程编程接口,Java程序可以直接使用这些API创建和管理线程。需要注意的是,Java线程API在底层依赖于宿主操作系统的线程库实现,在Windows平台上使用Windows线程库,在Linux或macOS平台上使用Pthreads实现。
关于线程间数据共享,Pthreads和Windows线程库中,在函数外部声明的全局变量会被同一进程内的所有线程共享。Java作为纯面向对象语言,没有全局变量的概念,如果多个线程需要共享数据,开发者需要显式设计数据共享机制,例如通过对象实例字段、静态变量或专门设计的共享数据结构来实现。
在深入线程创建的具体实现之前,我们需要了解创建多个线程的两种基本策略:异步线程和同步线程。异步线程策略中,父线程创建子线程后立即恢复执行,父线程和子线程并发且独立地执行各自的任务。由于线程之间相对独立,异步线程策略通常涉及较少的数据共享。这种策略广泛应用于多线程服务器架构和响应式用户界面设计,允许主线程继续处理新的请求或用户交互,而工作线程在后台执行耗时操作。
同步线程策略要求父线程创建一个或多个子线程后,必须等待所有子线程完成执行并终止后才能继续执行。在这种策略中,子线程并发执行各自分配的工作任务,但父线程的执行被阻塞,直到所有子线程完成工作并与父线程连接(join)。同步线程策略通常用于需要线程间密切协作的场景,涉及重要的数据共享和结果聚合。例如,父线程可能将计算任务分配给多个子线程并行执行,然后等待所有子线程完成,最后将各个子线程的计算结果组合成最终结果。
Pthreads指的是定义线程创建和同步API的POSIX标准(IEEE 1003.1c)。这是线程行为的规范,而不是实现。 操作系统设计者可以以他们希望的任何方式实现该规范。许多系统实现了Pthreads规范;大多数是UNIX类型系统,包括Linux和macOS。虽然Windows不原生支持Pthreads,但Windows的一些第三方实现是可用的。
下面是一个使用Pthreads API构建多线程程序的C程序示例,该程序在单独的线程中计算非负整数的求和:
|#include <pthread.h> #include <stdio.h> #include <stdlib.h> int sum; /* 这个数据由线程共享 */ void *runner(void *param); /* 线程调用这个函数 */ int main(int argc, char *argv[]) { pthread_t tid; /* 线程标识符 */ pthread_attr_t attr; /* 线程属性集 */ /* 设置线程的默认属性 */
在这个程序中,所有Pthreads程序都必须包含pthread.h头文件。语句pthread_t tid声明我们要创建的线程的标识符。每个线程都有一组属性,包括栈大小和调度信息。
pthread_attr_t attr声明表示线程的属性。我们在函数调用pthread_attr_init(&attr)中设置属性。因为我们没有明确设置任何属性,所以我们使用提供的默认属性。
使用pthread_create()函数调用创建单独的线程。除了传递线程标识符和线程属性外,我们还传递新线程将开始执行的函数名称——在这种情况下是runner()函数。最后,我们传递在命令行提供的整数参数argv[1]。
此时,程序有两个线程:main()中的初始(或父)线程和在runner()函数中执行求和操作的求和(或子)线程。这个程序遵循线程创建/连接策略,在创建求和线程后,父线程将通过调用pthread_join()函数等待它终止。
求和线程将在调用函数pthread_exit()时终止。一旦求和线程返回,父线程将输出共享数据sum的值。
使用Windows线程库创建线程的技术在几个方面与Pthreads技术相似。我们在下面显示的C程序中说明了Windows线程API:
|#include <windows.h> #include <stdio.h> DWORD Sum; /* 数据由线程共享 */ /* 线程将在这个函数中执行 */ DWORD WINAPI Summation(LPVOID Param) { DWORD Upper = *(DWORD*)Param; for (DWORD i = 1; i <= Upper; i++) Sum += i; return 0; }
与Pthreads示例类似,这里的Sum变量声明为全局变量,同一进程内的所有线程都可以访问(DWORD类型表示32位无符号整数)。Summation()函数作为线程的入口点,线程启动后将执行此函数。该函数接收一个参数,类型为LPVOID(通用指针类型),用于传递计算上界值。线程执行从1累加到指定上界的计算,结果存储在共享变量Sum中。
Windows线程创建通过CreateThread()函数实现,其参数设计理念与Pthreads的pthread_create()函数相似。CreateThread()函数接受多个参数,包括安全属性、栈大小、线程入口函数、传递给线程的参数、创建标志(如是否在创建时挂起线程)以及用于返回线程标识符的指针。本示例中所有参数均使用默认值,未进行特殊配置。
线程是Java程序中程序执行的基本模型,Java语言及其API为线程的创建和管理提供了丰富的功能集。所有Java程序至少包含一个控制线程——即使只包含main()方法的简单Java程序也在JVM中作为单个线程运行。 Java线程在提供JVM的任何系统上都可用,包括Windows、Linux和macOS。Java线程API也可用于Android应用程序。
在Java程序中有两种明确创建线程的技术。一种方法是创建一个从Thread类派生的新类并重写其run()方法。另一种更常用的技术是定义一个实现Runnable接口的类。
这个接口定义了一个签名为public void run()的抽象方法。实现Runnable的类的run()方法中的代码是在单独线程中执行的代码。
|class Task implements Runnable { public void run() { System.out.println("我是一个线程。"); } } // 创建线程 Thread worker = new Thread(new Task()); worker.start();
从Java 1.8版本开始,Java引入了Lambda表达式,它允许使用更简洁的语法创建线程。不是定义实现Runnable的单独类,而是可以使用Lambda表达式:
|Runnable task = () -> { System.out.println("我是一个线程。"); }; Thread worker = new Thread(task); worker.start();
Lambda表达式以及称为闭包的类似函数是函数式编程语言的一个突出特性,在Python、C++和C#等非函数式语言中也可用。Lambda表达式通常为开发并行应用程序提供简单的语法。
Java中的线程创建涉及创建Thread对象并向其传递实现Runnable的类的实例,然后在Thread对象上调用start()方法。为新Thread对象调用start()方法做两件事:它在JVM中分配内存并初始化新线程,它调用run()方法,使线程有资格被JVM运行。
回想一下,Pthreads和Windows库中的父线程分别使用pthread_join()和WaitForSingleObject()来等待求和线程完成后再继续。Java中的join()方法提供类似的功能:
|try { worker.join(); } catch (InterruptedException ie) { }
如果主线程需要等待多个子线程全部完成后再继续执行,可以在循环中依次调用每个线程的join()方法,等待所有线程终止。这种模式与Pthreads中的线程连接策略类似,确保所有并发任务完成后再进行后续处理。
Java从诞生以来就支持使用我们迄今为止描述的方法创建线程。然而,从1.5版本及其API开始,Java引入了几个新的并发特性,为开发者提供了对线程创建和通信的更大控制。这些工具在java.util.concurrent包中可用。 不是显式创建Thread对象,线程创建而是围绕Executor接口组织:
|public interface Executor { void execute(Runnable command); }
实现这个接口的类必须定义execute()方法,该方法被传递一个Runnable对象。对于Java开发者来说,这意味着使用Executor而不是创建单独的Thread对象并调用其start()方法。Executor的使用如下:
|Executor service = new Executor; service.execute(new Task());
执行器框架基于生产者-消费者模型;实现Runnable接口的任务被生产,执行这些任务的线程消费它们。这种方法的优点是它不仅将线程创建与执行分离,而且还提供了并发任务之间通信的机制。
属于同一进程的线程之间的数据共享在Windows和Pthreads中很容易发生,因为共享数据只是全局声明。作为纯面向对象语言,Java没有这样的全局数据概念。我们可以向实现Runnable的类传递参数,但Java线程不能返回结果。 为了解决这个需求,java.util.concurrent包还定义了Callable接口,它的行为类似于Runnable,但可以返回结果。从Callable任务返回的结果称为Future对象。可以从Future接口中定义的get()方法检索结果。
|import java.util.concurrent.*; class Summation implements Callable<Integer> { private int upper; public Summation(int upper) { this.upper = upper; } /* 线程将在这个方法中执行 */ public Integer call() { int sum = 0;
Summation类实现Callable接口,它指定方法V call()——在单独线程中执行的代码在这个call()方法中。 要执行这个代码,我们创建一个newSingleThreadExecutor对象(在Executors类中作为静态方法提供),它是ExecutorService类型,并使用其submit()方法向其传递Callable任务。 一旦我们向线程提交可调用任务,我们通过调用它返回的Future对象的get()方法来等待其结果。
随着多核处理器的广泛普及,现代应用程序往往需要管理数百甚至数千个线程。然而,显式线程管理面临诸多挑战,包括线程创建开销、同步复杂性、资源竞争以及程序正确性保证等。
为了简化并发和并行编程,现代编程语言和框架引入了隐式线程管理机制,将线程的创建、调度和生命周期管理交由编译器和运行时系统处理,开发者只需专注于识别可并行化的计算任务和业务逻辑实现。这种编程范式称为隐式线程,已成为现代并发编程的主流趋势。本节将介绍四种利用隐式线程机制充分发挥多核处理器性能的技术。
这些隐式线程技术的基本原理是,开发者只需声明哪些计算任务可以并发执行,而无需显式创建和管理线程。这些任务通常以函数或代码块的形式表达,运行时库自动将这些任务分配到可用的执行线程上,通常采用多对多的映射策略,即多个任务可以映射到多个线程。这种抽象使得开发者能够专注于任务本身的逻辑,而将线程分配、调度和管理的复杂性交由运行时系统处理。
线程池是一种预先创建并维护一组工作线程的并发编程模式,用于执行提交的任务。与为每个任务动态创建新线程相比,线程池通过重用现有线程来减少线程创建和销毁的开销。当任务到达时,线程池将任务分配给空闲的工作线程执行;任务完成后,工作线程返回线程池等待下一个任务。如果任务数量超过可用线程数,多余的任务将在队列中等待,直到有线程可用。
线程池机制带来三个主要优势。首先是响应性能提升,预先创建的工作线程处于就绪状态,任务到达后可以立即执行,避免了线程创建的系统调用开销。其次是资源控制,线程池限制了并发线程的最大数量,防止系统资源被过度消耗,避免因线程数量过多导致的上下文切换开销和内存压力。第三是调度灵活性,线程池支持多种调度策略,如固定大小线程池、可扩展线程池、定时任务执行等,可以根据应用需求灵活配置。
实际应用中,线程池的大小需要根据系统处理核心数量、可用内存资源、任务特性和负载模式进行调优。高级线程池实现还支持动态调整线程数量,根据当前负载自动增加或减少工作线程,在保证性能的同时优化资源利用率。
java.util.concurrent包提供了多种线程池实现。我们重点介绍三种典型的线程池模型。单线程执行器通过newSingleThreadExecutor()方法创建,线程池大小为1,适用于需要顺序执行任务的场景。固定线程执行器通过newFixedThreadPool(int size)方法创建,维护固定数量的工作线程,适用于负载稳定的并发场景。缓存线程执行器通过newCachedThreadPool()方法创建,线程池大小无界,根据任务负载动态创建和回收线程,适用于任务数量波动较大的场景。
|import java.util.concurrent.*; public class ThreadPoolExample { public static void main(String[] args) { int numTasks = Integer.parseInt(args[0].trim()); /* 创建线程池 */ ExecutorService pool = Executors.newCachedThreadPool(); /* 使用池中的线程运行每个任务 */ for (
线程池通过Executors类提供的工厂方法创建,包括newSingleThreadExecutor()、newFixedThreadPool(int size)和newCachedThreadPool()。这些工厂方法均返回实现ExecutorService接口的对象实例。ExecutorService接口扩展了Executor接口,提供了execute()方法用于提交任务执行,同时还提供了管理线程池生命周期的方法,如shutdown()用于优雅关闭线程池。
Fork-Join模型是一种基于任务分解与结果聚合的并行计算模式。在这种模式下,主线程将计算任务递归分解为多个子任务,分配给不同的工作线程并行执行,待所有子任务完成后,主线程将各个子任务的执行结果聚合为最终结果。这种模式特别适用于可以递归分解的算法,如分治算法。
Fork-Join模型与显式线程创建的区别在于,开发者只需定义任务分解和结果聚合的逻辑,而线程的创建、分配和管理由运行时系统自动处理。运行时系统根据任务数量、系统处理核心数量和当前负载情况,自动决定创建多少工作线程,并负责任务的调度和负载均衡。
从执行模式角度看,Fork-Join模型可以视为同步版本的线程池:线程池中的工作线程持续运行并处理异步提交的任务,而Fork-Join模型则是为特定计算任务动态创建工作线程,任务完成后线程可以回收或用于其他任务。运行时系统会根据任务特性和系统资源自动优化线程数量。
Java在API的1.7版本中引入了fork-join库,该库设计用于递归分治算法,如快速排序和归并排序。当使用这个库实现分治算法时,在分治步骤中fork单独的任务并分配原始问题的较小子集。算法必须设计为使这些单独的任务可以并发执行。在某个时候,分配给任务的问题大小足够小,可以直接解决,不需要创建额外的任务。 Java fork-join策略背后的通用递归算法如下:
|Task(problem) if problem is small enough solve the problem directly else subtask1 = fork(new Task(subset of problem) subtask2 = fork(new Task(subset of problem) result1 = join(subtask1) result2 = join(subtask2) return combined results
我们通过设计一个分治算法来说明Java的fork-join策略,该算法对整数数组中的所有元素求和。在API的1.7版本中,Java引入了一个新的线程池——ForkJoinPool——可以分配继承抽象基类ForkJoinTask的任务(现在我们将假设它是SumTask类)。 以下创建一个ForkJoinPool对象并通过其invoke()方法提交初始任务:
|ForkJoinPool pool = new ForkJoinPool(); // array包含要求和的整数 int[] array = new int[SIZE]; SumTask task = new SumTask(0, SIZE - 1, array); int sum = pool.invoke(task);
完成后,对invoke()的初始调用返回数组的求和。
|import java.util.concurrent.*; public class SumTask extends RecursiveTask<Integer> { static final int THRESHOLD = 1000; private int begin; private int end; private int[] array; public SumTask(int begin, int end, int
SumTask类实现了分治算法,将大数组递归分解为较小的子数组,每个子任务独立计算局部和,最后将子任务的结果聚合为全局和。compute()方法实现了递归逻辑:当数据范围小于阈值时,直接计算该范围内的和;否则将范围一分为二,创建两个子任务分别处理左右两部分,通过fork()方法提交子任务执行,通过join()方法等待子任务完成并获取结果。
SumTask继承自RecursiveTask类,这是Java fork-join框架的核心抽象。Java的fork-join机制基于ForkJoinTask抽象基类设计,RecursiveTask和RecursiveAction是其两个主要子类。RecursiveTask用于需要返回值的递归任务,如数组求和;RecursiveAction用于不需要返回值的递归任务,如数组排序。
OpenMP是C、C++或FORTRAN程序的一组编译器指令以及API,为共享内存环境中的并行编程提供支持。 OpenMP识别并行区域作为可能并行运行的代码块。应用程序开发者在并行区域中向代码插入编译器指令,这些指令指示OpenMP运行时库并行执行该区域。
|#include <omp.h> #include <stdio.h> int main(int argc, char *argv[]) { /* 顺序代码 */ #pragma omp parallel { printf("我是一个并行区域。"); } /* 顺序代码 */ return 0; }
当OpenMP遇到指令#pragma omp parallel时,它创建与系统中处理核心一样多的线程。因此,对于双核系统,创建两个线程;对于四核系统,创建四个线程;等等。所有线程然后同时执行并行区域。当每个线程退出并行区域时,它被终止。
OpenMP提供了几个额外的指令来并行运行代码区域,包括并行化循环。例如,假设我们有两个大小为N的数组a和b。我们希望求和它们的内容并将结果放在数组c中。我们可以使用以下代码段让这个任务并行运行,该代码段包含并行化for循环的编译器指令:
|#pragma omp parallel for for (i = 0; i < N; i++) { c[i] = a[i] + b[i]; }
OpenMP将包含在for循环中的工作分配给它在响应指令#pragma omp parallel for时创建的线程。
除了提供并行化指令外,OpenMP还允许开发者选择几个并行级别。例如,他们可以手动设置线程数量。它还允许开发者识别数据是在线程之间共享还是对线程是私有的。OpenMP在Linux、Windows和macOS系统的几个开源和商业编译器上可用。
Grand Central Dispatch(GCD)是苹果公司为macOS和iOS平台开发的并发编程框架,它将线程管理的复杂性封装在运行时系统中,开发者只需定义任务并提交到调度队列,GCD自动将任务分配到合适的线程执行。GCD采用基于队列的任务调度模型,任务被提交到调度队列后,系统从线程池中选择空闲线程执行这些任务。
GCD提供两种类型的调度队列:串行队列和并发队列。串行队列中的任务按提交顺序依次执行,前一个任务完成后才执行下一个任务,适用于需要严格顺序执行的场景。每个进程都有一个主串行队列,用于执行用户界面更新等操作,开发者也可以创建自定义串行队列。并发队列允许多个任务同时从队列中取出并发执行,充分利用多核处理器的并行计算能力。
GCD根据任务的优先级和重要性,将并发队列中的任务划分为四种服务质量等级(Quality of Service,QOS)。用户交互级别(QOS_CLASS_USER_INTERACTIVE)用于界面响应等对延迟极其敏感的任务,系统会优先调度这些任务以保证界面流畅性。用户启动级别(QOS_CLASS_USER_INITIATED)用于用户主动触发的操作,系统会尽快完成这些任务以提升用户体验。实用工具级别(QOS_CLASS_UTILITY)用于可以延迟执行的后台任务,如批量数据处理、定期同步等。后台级别(QOS_CLASS_BACKGROUND)用于用户几乎无感知的后台工作,如自动备份、索引构建等,系统在资源空闲时执行这些任务。通过这种服务质量分级机制,GCD能够智能分配CPU资源,在保证用户交互响应性的同时,高效执行后台任务。 提交给调度队列的任务可以用两种不同的方式表达:
|^{ printf("我是一个块"); }
以下Swift代码段说明了获取用户启动类的并发队列并使用dispatch_async()函数向队列提交任务:
|let queue = dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0) dispatch_async(queue, { print("我是一个闭包。") })
在内部,GCD的线程池由POSIX线程组成。GCD主动管理池,允许线程数量根据应用程序需求和系统容量增长和收缩。GCD由libdispatch库实现,Apple已根据Apache Commons许可证发布。它后来被移植到FreeBSD操作系统。
隐式线程机制类似于智能任务调度系统。开发者无需手动管理每个线程的创建、调度和销毁,只需声明需要执行的任务,运行时系统自动进行线程分配和任务调度,确保计算资源的高效利用。
Intel线程构建块(TBB)是一个支持在C++中设计并行应用程序的模板库。由于这是一个库,它不需要特殊的编译器或语言支持。开发者指定可以并行运行的任务,TBB任务调度器将这些任务映射到底层线程。此外,任务调度器提供负载平衡和缓存感知,这意味着它会优先考虑可能将其数据存储在缓存内存中并因此执行更快的任务。
TBB提供了丰富的功能集,包括并行循环结构的模板、原子操作和互斥锁定。此外,它还提供并发数据结构,包括哈希映射、队列和向量,可以作为C++标准模板库数据结构的等效线程安全版本。
让我们使用并行for循环作为例子。最初,假设有一个名为apply(float value)的函数对参数值执行操作。如果我们有一个大小为n的包含浮点值的数组v,我们可以使用以下串行for循环将v中的每个值传递给apply()函数:
|for (int i = 0; i < n; i++) { apply(v[i]); }
开发者可以在多核系统上手动应用数据并行,通过将数组v的不同区域分配给每个处理核心;然而,这使实现并行的技术与物理硬件紧密相关,算法必须针对每个特定架构的处理核心数量进行修改和重新编译。
或者,开发者可以使用TBB,它提供了一个并行for模板,期望两个值:
|parallel_for(range, body)
其中range指的是将被迭代的元素范围(称为迭代空间),body指定将在元素子范围上执行的操作。 我们现在可以使用TBB并行for模板重写上面的串行for循环:
|parallel_for(size_t(0), n, [=](size_t i) {apply(v[i]);});
前两个参数指定迭代空间从0到n-1(对应于数组v中元素的数量)。第二个参数是一个C++ lambda函数,需要一点解释。表达式[=](size_t i)是参数i,它假设迭代空间上的每个值(在这种情况下从0到n-1)。i的每个值用于标识v中的哪个数组元素将作为参数传递给apply(v[i])函数。
TBB库将循环迭代分成单独的"块"并创建在这些块上操作的任务数量。(parallel_for函数允许开发者手动指定块的大小,如果他们希望的话。)TBB还将创建一些线程并将任务分配给可用线程。这与Java中的fork-join库非常相似。这种方法的优点是它只需要开发者识别什么操作可以并行运行(通过指定并行for循环),库管理将工作分成并行运行的单独任务所涉及的细节。

在多线程环境中,fork()和exec()系统调用的行为与单线程程序存在显著差异。fork()系统调用用于创建当前进程的副本,但在多线程程序中,fork()的行为取决于具体实现:新进程可能复制所有线程,也可能仅复制调用fork()的线程。
某些UNIX系统提供了两种fork()变体:一种复制进程的所有线程到新进程,另一种仅复制调用fork()的当前线程。exec()系统调用的行为在多线程环境中与单线程环境一致:任何线程调用exec()后,整个进程(包括所有线程)将被新程序映像替换,原进程的所有线程终止。
选择何种fork()行为取决于应用程序的需求。如果fork()后立即调用exec()(这是fork-exec模式的典型用法),则无需复制所有线程,因为新进程将被新程序替换,此时仅复制当前线程即可,减少不必要的开销。如果fork()创建的新进程需要继续使用原进程的线程结构,则必须复制所有线程,以确保新进程能够正常执行。
信号在UNIX系统中用于通知进程特定事件的发生。信号可以同步或异步接收,取决于信号事件的来源和性质。所有信号,无论同步还是异步,都遵循相同的处理模式:信号由特定事件触发生成,被传递给目标进程,一旦传递成功,信号必须被处理。
同步信号的例子包括非法内存访问和除以0。如果运行的程序执行这些操作中的任何一个,就会生成信号。同步信号被传递给执行导致信号的操作的同一进程(这就是为什么它们被认为是同步的)。 当信号由运行进程外部的事件生成时,该进程异步接收信号。此类信号的例子包括用特定按键终止进程(如Ctrl+C)和定时器过期。通常,异步信号被发送到另一个进程。
信号可以由两种处理器之一处理:默认信号处理器由内核提供,或用户定义的信号处理器由应用程序注册。
每个信号都有一个默认信号处理器,内核在处理该信号时运行。这个默认操作可以被调用来处理信号的用户定义信号处理器覆盖。信号以不同方式处理。一些信号可能被忽略,而其他信号(例如,非法内存访问)通过终止程序来处理。
在单线程程序中,信号处理相对简单:信号总是被传递给进程。但在多线程程序中,信号传递机制更为复杂,因为进程可能包含多个线程。信号传递策略包括:将信号传递给触发该信号的特定线程(适用于同步信号),将信号传递给进程中的所有线程,将信号传递给进程中的部分线程,或指定一个专用线程接收进程的所有信号。
传递信号的方法取决于生成的信号类型。例如,同步信号需要被传递给导致信号的线程,而不是进程中的其他线程。但是,异步信号的情况不太清楚。一些异步信号——如终止进程的信号(例如Ctrl+C)——应该被发送到所有线程。 传递信号的标准UNIX函数是:
|kill(pid_t pid, int signal)
这个函数指定特定信号(signal)将被传递到的进程(pid)。大多数多线程版本的UNIX允许线程指定它将接受哪些信号以及它将阻塞哪些信号。 因此,在某些情况下,异步信号可能只被传递给那些不阻塞它的线程。但是,因为信号只需要处理一次,信号通常只被传递给发现不阻塞它的第一个线程。POSIX Pthreads提供以下函数,允许信号被传递给指定的线程(tid):
|pthread_kill(pthread_t tid, int signal)
虽然Windows不明确提供对信号的支持,但它允许我们使用异步过程调用(APC)来模拟它们。APC设施使用户线程能够指定当用户线程接收到特定事件的通知时要调用的函数。正如其名称所示,APC大致相当于UNIX中的异步信号。但是,虽然UNIX必须处理如何在多线程环境中处理信号,但APC设施更直接,因为APC被传递给特定线程而不是进程。
线程取消机制允许在线程正常完成之前终止其执行。典型的应用场景包括:多个线程并发搜索数据库时,一旦某个线程找到结果,其他搜索线程可以被取消以节省资源;用户点击浏览器停止按钮时,所有正在加载网页资源的线程需要被取消。
被取消的线程称为目标线程。线程取消可以采用两种策略:异步取消模式下,目标线程被立即终止,不进行任何清理操作;延迟取消模式下,目标线程定期检查取消标志,在检测到取消请求后,可以在安全的取消点有序地终止执行,并执行必要的资源清理操作。
取消的困难发生在资源已分配给被取消线程或线程在更新与其他线程共享的数据时被取消的情况下。这在异步取消时变得特别麻烦。 通常,操作系统会从被取消的线程中回收系统资源,但不会回收所有资源。因此,异步取消线程可能不会释放必要的系统范围资源。
相比之下,使用延迟取消,一个线程指示目标线程要被取消,但取消只有在目标线程检查标志以确定是否应该被取消后才发生。线程可以在可以安全取消的点执行此检查。 在Pthreads中,线程取消使用pthread_cancel()函数启动。目标线程的标识符作为参数传递给函数。以下代码说明了创建——然后取消——一个线程:
|pthread_t tid; /* 创建线程 */ pthread_create(&tid, 0, worker, NULL); ... /* 取消线程 */ pthread_cancel(tid); /* 等待线程终止 */ pthread_join(tid, NULL);
调用pthread_cancel()只表示取消目标线程的请求;实际取消取决于目标线程如何设置来处理请求。当目标线程最终被取消时,取消线程中对pthread_join()的调用返回。Pthreads支持三种取消模式。每种模式都定义为状态和类型,如下表所示。线程可以使用API设置其取消状态和类型。
Pthreads允许线程控制是否响应取消请求。如果线程禁用取消功能,即使收到取消请求,线程也会继续执行,但取消请求会被保存,待取消功能重新启用时生效。
默认情况下,Pthreads采用延迟取消模式,线程不会立即终止,而是在到达取消点时检查取消请求并安全退出。取消点是线程可以安全响应取消请求的执行点,许多阻塞系统调用(如read())在等待I/O时就是典型的取消点。如果线程在取消点处阻塞时收到取消请求,可以在此处安全终止。
开发者可以在代码中显式调用pthread_testcancel()函数主动检查取消请求。线程执行到此函数时会检查是否有待处理的取消请求,如有则退出,否则继续执行。此外,Pthreads支持注册清理处理程序,线程在被取消前可以执行清理处理程序,释放已分配的资源,避免资源泄漏。
以下代码说明了线程如何使用延迟取消响应取消请求:
|while (1) { /* 做一些工作 */ ... /* 检查是否有取消请求 */ pthread_testcancel(); }
由于异步取消可能导致资源泄漏和数据不一致等问题,Pthreads规范不推荐使用异步取消模式,因此本文不再详细讨论。需要说明的是,在Linux系统中,Pthreads的线程取消机制底层通过信号机制实现。
Java中的线程取消使用类似于Pthreads中延迟取消的策略。要取消Java线程,你调用interrupt()方法,它将目标线程的中断状态设置为true:
|Thread worker; ... /* 设置线程的中断状态 */ worker.interrupt();
线程可以通过调用isInterrupted()方法检查其中断状态,该方法返回线程中断状态的布尔值:
|while (!Thread.currentThread().isInterrupted()) { ... }
同一进程内的多个线程共享进程的地址空间和全局数据,这种共享机制便于线程间通信和数据交换。然而,某些场景下每个线程需要维护独立的数据副本,这些数据对其他线程不可见,这种机制称为线程本地存储(Thread Local Storage,TLS)。
以银行系统为例,每个客户请求由独立线程处理,每个线程需要维护自己的客户标识符以区分不同的客户上下文。TLS机制使得每个线程拥有独立的客户标识符副本,线程间互不干扰。
TLS与局部变量存在本质区别:局部变量的作用域仅限于定义它的函数内部,函数返回后变量生命周期结束;而TLS中的数据在整个线程生命周期内有效,可以在线程的不同函数调用间共享。在使用线程池的场景中,线程由系统创建和管理,开发者无法控制线程的生命周期,TLS机制允许为每个线程分配独立的数据空间,无需关心线程的创建方式。
TLS与静态变量也有区别:静态变量在进程内所有线程间共享同一份数据,而TLS为每个线程维护独立的数据副本。主流编程语言和库均提供TLS支持:Java提供ThreadLocal类,通过set()和get()方法访问线程本地数据;Pthreads提供pthread_key_t类型和相关的键值管理函数;C#使用[ThreadStatic]属性标记线程静态变量;gcc编译器支持__thread关键字。例如,使用gcc的__thread关键字为每个线程分配独立编号:
|static __thread int threadID;
在多线程程序设计中,特别是在多对多或两级线程模型中,操作系统内核与用户线程库之间的协作机制至关重要,这直接影响线程调度的灵活性和系统性能。
许多系统在用户线程和内核线程之间引入了轻量级进程(Lightweight Process,LWP)作为中间抽象层。LWP可以视为虚拟处理器,用户线程库将用户线程调度到LWP上执行。每个LWP对应一个内核线程,操作系统将内核线程调度到物理CPU核心上执行。当内核线程因等待I/O操作(如磁盘读写、网络通信)而阻塞时,对应的LWP也会阻塞,挂载在该LWP上的用户线程无法继续执行。
LWP的数量需求取决于应用程序特性。CPU密集型程序可能只需要一个LWP,因为计算任务通常不会阻塞,一次只能有一个线程在CPU上执行。I/O密集型程序则需要多个LWP,以支持并发I/O操作。例如,同时读取五个文件需要五个LWP,每个LWP可能因等待I/O而阻塞;如果只有四个LWP,第五个文件读取请求必须等待某个LWP可用。
调度器激活(Scheduler Activation)机制实现了用户线程库与内核之间的协作通信。内核为应用程序分配一组LWP作为虚拟处理器,应用程序将用户线程调度到这些LWP上执行。内核在关键事件发生时通过upcall机制通知应用程序,upcall处理程序在LWP上执行。
具体工作流程如下:当某个线程即将因I/O操作阻塞时,内核发送upcall通知应用程序该线程即将阻塞,并分配新的LWP。应用程序使用新LWP保存即将阻塞线程的执行状态,释放原LWP供其他线程使用。当I/O操作完成时,内核再次发送upcall通知线程库,线程库将之前阻塞的线程重新调度到可用LWP上继续执行。这种机制实现了内核与线程库之间的动态协作,能够根据线程阻塞情况和系统负载灵活调整LWP分配,优化系统资源利用率和程序执行效率。
本节系统阐述了线程与并发编程的核心概念和技术。线程作为进程内的基本执行单元,共享进程的地址空间和系统资源,但每个线程拥有独立的栈空间和寄存器上下文。这种设计使得程序能够并发执行多个任务,充分利用现代多核处理器的并行计算能力。
多线程编程带来四个核心优势:响应性提升使得程序在执行阻塞操作时仍能保持对其他任务的响应能力;资源共享机制简化了线程间的数据通信;线程创建和上下文切换的经济性使其比进程操作更加高效;可扩展性使得应用程序能够充分利用多核处理器的并行计算资源。在现代多核系统架构中,多线程编程已从性能优化技术转变为构建高效应用程序的必备技能。
我们还深入探讨了用户线程与内核线程的映射模型、主流线程库的API设计和使用方法,以及现代隐式线程管理技术。这些技术将线程的创建、调度和管理复杂性封装在运行时系统中,使开发者能够专注于业务逻辑实现,显著降低了并发编程的复杂度,提高了开发效率和程序可维护性。
线程作为CPU使用的基本单位,它包含哪些独立的组件?
多线程编程为应用程序带来哪些核心优势?
并发与并行之间的主要区别是什么?
在多线程模型中,哪个模型的特点是多个用户线程映射到一个内核线程,且无法在多核系统上并行运行?
哪个操作系统实现了一对一线程模型?
在Pthreads中,用于创建新线程的函数是哪个?
从Java 1.5版本开始,Java线程创建围绕哪个接口组织?
关于线程池机制,以下哪个描述是正确的?
关于Fork-Join并行模型,以下哪个描述是正确的?
关于线程本地存储(TLS),以下哪个描述是正确的?
问题1:请解释多对一、一对一和多对多线程模型各自的优缺点,并说明为什么现代操作系统普遍采用一对一模型。
多对一模型将多个用户级线程映射到一个内核线程,线程管理由用户空间中的线程库完成,因此效率很高。但是,如果线程进行阻塞系统调用,整个进程将被阻塞。此外,因为一次只有一个线程可以访问内核,多个线程无法在多核系统上并行运行。
一对一模型将每个用户线程映射到一个内核线程,它通过允许另一个线程在线程进行阻塞系统调用时运行,提供了比多对一模型更多的并发性。它还允许多个线程在多处理器上并行运行。这个模型的唯一缺点是创建用户线程需要创建相应的内核线程,大量的内核线程可能会给系统性能带来负担。
多对多模型将多个用户线程映射到多个内核线程,结合了前两种模型的优势。它允许创建任意数量的用户线程,同时根据实际需求灵活分配内核线程数量,既能实现并行执行,又不会造成过度的系统负担。
现代操作系统普遍采用一对一模型,因为多核处理器的普及使得内核线程数量增加带来的开销可以接受。多对多模型虽然理论上具有灵活性优势,但其实现复杂度较高。
问题2:请说明线程池、Fork-Join、OpenMP和Grand Central Dispatch这四种隐式线程技术的核心思想和使用场景。
线程池是一种预先创建并维护一组工作线程的并发编程模式,用于执行提交的任务。它通过重用现有线程来减少线程创建和销毁的开销,适用于需要频繁创建和销毁线程的场景,如Web服务器处理大量并发请求。
Fork-Join模型是一种基于任务分解与结果聚合的并行计算模式,主线程将计算任务递归分解为多个子任务,分配给不同的工作线程并行执行,待所有子任务完成后聚合结果。这种模式特别适用于可以递归分解的算法,如快速排序和归并排序等分治算法。
OpenMP是C、C++或FORTRAN程序的一组编译器指令以及API,为共享内存环境中的并行编程提供支持。开发者只需在并行区域中插入编译器指令,OpenMP运行时库会自动并行执行该区域。它适用于需要并行化循环和代码块的场景,特别适合科学计算和数值分析。
Grand Central Dispatch(GCD)是苹果公司为macOS和iOS平台开发的并发编程框架,它将线程管理的复杂性封装在运行时系统中,开发者只需定义任务并提交到调度队列。GCD采用基于队列的任务调度模型,根据任务的优先级和重要性分配CPU资源,适用于需要智能任务调度的移动应用和桌面应用开发。
问题3:请解释阿姆达尔定律的含义,并说明为什么它揭示了并行计算的性能瓶颈。
阿姆达尔定律是一个公式,它识别了向具有串行(非并行)和并行组件的应用程序添加额外计算核心的潜在性能增益。如果S是必须在具有N个处理核心的系统上串行执行的应用程序部分,公式为:加速比 ≤ 1 / (S + (1-S)/N)。
举例说明,假设一个应用程序中75%的代码可以并行执行,剩余25%必须串行执行。在双核处理器系统上,理论加速比约为1.6倍;在四核处理器系统上,理论加速比提升至约2.28倍。
阿姆达尔定律揭示了一个重要特性:当处理器数量趋于无穷大时,程序的加速比不会无限增长,而是收敛于1/S的极限值。例如,如果应用程序中50%的代码必须串行执行(S=0.5),无论增加多少处理器,理论最大加速比仅为2倍。
这一特性表明,应用程序中不可并行化的串行部分构成了性能提升的根本性瓶颈,严重限制了多核系统带来的性能增益。因此,在设计并行程序时,开发者需要尽可能减少串行部分的比例,或者采用更细粒度的并行化策略,才能充分发挥多核处理器的性能优势。