并发编程基础全面解析
1. 并发编程基础概念
1.1 并发与并行
并发指的是两个或多个执行上下文可以同时处于活跃状态的程序。在这个定义下,协程不属于并发,因为同一时刻只能有一个协程处于活跃状态。而并行则是指在多个上下文中同时进行实际执行的并发程序,真正的并行需要并行硬件的支持。从语义角度看,真正的并行和抢占式并发系统的“准并行”(在不可预测的时间在执行上下文之间切换)没有区别,在这两种情况下都适用相同的编程技术。
1.2 线程、进程与任务
在并发程序中,执行上下文被称为线程。一个程序的线程通常基于操作系统提供的一个或多个进程来实现。操作系统设计者常区分重量级进程(有自己的地址空间)和轻量级进程(可以共享地址空间)。轻量级进程在 20 世纪 80 年代末和 90 年代初被添加到大多数 Unix 变体中,以适应共享内存多处理器的普及。
任务是指某个线程必须执行的明确定义的工作单元。在一种常见的编程模式中,一组线程共享一个“任务包”,即一个待完成的工作列表。每个线程反复从任务包中取出一个任务,执行它,然后再取另一个任务。有时任务的工作还包括向任务包中添加新任务。
然而,并发编程的词汇在不同语言和作者之间并不一致。例如,几种语言将它们的线程称为进程,Ada 称它们为任务,一些操作系统将轻量级进程称为线程,Mach OS 将轻量级进程共享的地址空间称为任务。一些系统通过创造新术语(如“参与者”或“细丝”)来避免歧义。
1.3 通信与同步
在任何并发编程模型中,通信和同步是两个至关重要的问题。
-
通信
:指的是允许一个线程获取另一个线程产生的信息的任何机制。对于命令式程序,通信机制通常基于共享内存或消息传递。在共享内存编程模型中,程序的部分或全部变量可被多个线程访问,两个线程通过一个线程向变量写入值,另一个线程读取该值来通信。在消息传递编程模型中,线程没有共同状态,两个线程通信时,一个线程必须执行显式的发送操作将数据传输给另一个线程。
-
同步
:指的是允许程序员控制不同线程中操作发生的相对顺序的任何机制。在消息传递模型中,同步通常是隐式的,因为消息必须先发送才能接收。如果一个线程试图接收尚未发送的消息,它将等待发送者。而在共享内存模型中,同步通常不是隐式的,除非采取特殊措施,否则“接收”线程可能在“发送”线程写入变量之前读取其“旧”值。
同步可以通过忙等待(也称为自旋等待)或阻塞来实现:
-
忙等待同步
:线程运行一个循环,不断重新评估某个条件,直到该条件变为真(例如,直到消息队列变为非空或共享变量达到特定值)。但在单处理器上,忙等待对于线程同步没有意义,因为在垄断使条件为真所需的资源(处理器)时,不能期望条件变为真。不过,单处理器上的线程有时可能会为 I/O 完成而忙等待,因为 I/O 设备与处理器并行运行。
-
阻塞同步
:等待的线程自愿将处理器让给其他线程。在此之前,它会在与同步条件相关的数据结构中留下一个记录。将来使条件为真的线程会找到该记录,并采取行动使阻塞的线程再次运行。
硬件和软件的通信也存在共享内存和消息传递的区别,并且语言或库提供的通信和同步模型不一定与底层硬件一致。可以在共享内存硬件上实现消息传递,也可以在消息传递硬件上实现共享内存,后者有时被称为软件分布式共享内存(S - DSM)。
2. 并发编程的语言和库
2.1 并发编程的实现方式
并发可以以显式并发语言、编译器支持的传统顺序语言扩展或语言本身之外的库包的形式提供给程序员。历史上,后两种方式更为常见,目前大多数并行程序要么是用于向量机的带注释的 Fortran 程序,要么是带有库调用的 C/C++ 代码。随着 Java 和 C# 的普及,这种情况至少在“低端”开始改变,但显式并行语言要取代 Fortran、C 和 C++ 在高性能并行应用中的地位可能还需要一段时间。
2.2 共享内存和消息传递的库
- 共享内存库 :大多数对称多处理器(SMP)供应商提供基于共享内存和线程的并行编程库。在 Unix 世界中,这个库通常实现 POSIX pthreads 标准,微软也为 Windows 提供了类似的功能。
- 消息传递库 :现有的消息传递库可分为两大类。一类主要用于单个程序的进程之间的通信,另一类主要用于跨程序边界的通信。后一类通常实现一种标准的 Internet 协议,类似于基于文件的 I/O。在并行程序中,最流行的两个消息传递包是 PVM 和 MPI。PVM 在异构分布式网络中创建和管理进程方面更丰富,而 MPI 对通信的实现提供了更多控制,并拥有更丰富的通信原语,特别是用于集体通信。PVM 和 MPI 都有 C、C++ 和 Fortran 的实现。
2.3 远程过程调用(RPC)
对于基于客户端向服务器请求的通信,远程过程调用(RPC)为消息传递提供了一个有吸引力的接口。RPC 客户端调用本地存根过程,该过程将其参数打包成消息,发送给服务器,然后等待响应,并将结果参数返回给客户端。一些供应商提供工具,可根据服务器接口的正式描述自动生成存根。在 Unix 世界中,Sun 的 RPC 是事实上的标准。在面向对象系统中,RPC 有时被称为远程方法调用(RMI)。
2.4 显式并发编程语言
与库包相比,显式并发编程语言具有编译器支持的优势。它可以使用除子例程调用之外的语法,并能将通信和线程管理与类型检查、作用域和异常等概念更紧密地集成。但由于大多数程序是顺序的,并发语言难以获得广泛接受,特别是当并发特性使顺序情况更难理解时。一些显式并发编程语言包括 Algol 68、Ada、Modula - 3、Java、C#、Occam 和 Andrews 的 SR。
在科学界,向多计算机和多处理器的并行化编译器过渡中,一些团体在 20 世纪 90 年代初联合开发了高性能 Fortran(HPF),它是 Fortran 90 的数据并行方言。数据并行程序的主要并行源是对大型数据集的成员应用共同操作,而任务并行程序的并行性主要源于同时执行不同操作。
3. 线程创建语法
3.1 常见的线程创建选项
大多数并发系统允许程序员在运行时创建新线程,语法和语义细节因语言或库而异。常见的选项有以下六种:
1.
co - begin
:使用特殊的控制流构造来界定线程。
2.
并行循环
:循环的迭代可以并发执行。
3.
launch - at - elaboration
:在声明被详细处理时创建线程。
4.
fork(可选 join)
:使线程创建成为显式的可执行操作,join 操作允许线程等待之前分叉的线程完成。
5.
隐式接收
:在 RPC 系统中,服务器可以将通信通道绑定到本地线程体或子例程,当请求到来时,自动创建新线程处理它。
6.
早期回复
:允许被调用者在不终止的情况下将结果返回给调用者,之后调用者和被调用者继续并发执行。
3.2 不同语言的线程创建示例
-
co - begin
:在 Algol 68 中,
begin... end块的行为取决于内部表达式是用分号还是逗号分隔。如果begin前面有par关键字,则表示并行执行。例如:
par begin
a := 3,
b := 4
end
Occam 也使用类似的
par
结构,通过缩进界定嵌套控制结构。
- 并行循环 :SR、Occam 和一些 Fortran 方言都提供并行循环。例如,在 SR 中:
co (i := 5 to 10) ->
p(a, b, i)
oc
在 Occam 中:
par i = 5 for 6
p(a, b, i)
HPF 的
forall
循环也用于并行迭代,它会对循环的组成语句进行自动内部同步,以解决竞争条件。例如:
forall (i=1:n-1)
A(i) = B(i) + C(i)
A(i+1) = A(i) + A(i+1)
end forall
- launch - at - elaboration :在 Ada 中,当任务声明被详细处理时,会创建线程执行代码。例如:
procedure P is
task T is
...
end T;
begin -- P
...
end P;
当控制进入
P
过程时,任务
T
开始执行。
-
fork/join
:Ada 允许定义任务类型,并通过动态分配创建新任务,新操作相当于
fork。Modula - 3 提供fork和join操作,fork返回一个线程引用,join以该引用为参数。例如:
t := Fork(c);
...
Join(t);
Java 通过继承
Thread
类创建线程,使用
start
方法启动线程,使用
join
方法等待线程完成。例如:
class image_renderer extends Thread {
...
image_renderer( args ) {
// constructor
}
public void run() {
// code to be run by the thread
}
}
...
image_renderer rend = new image_renderer( constructor args );
rend.start();
rend.join();
从 Java 5 开始,建议使用
Executor
对象管理线程池,将任务传递给它,由它分配给线程池中的线程执行。
-
隐式接收 :在 RPC 系统中,服务器可以将通信通道绑定到本地线程体或子例程,当请求到来时,自动创建新线程处理它。在 SR 中,通过声明能力变量并发送给另一个地址空间的线程来实现类似效果。
-
早期回复 :SR 中的
reply操作实现早期回复。例如:
reply
早期回复在一些应用中很有用,例如在网页浏览器中,负责格式化页面的线程创建子线程处理内联图像,子线程可以通过早期回复将图像大小信息返回给父线程,然后两者并行执行。
3.3 线程创建的流程图
graph LR
A[开始] --> B{选择线程创建方式}
B --> |co - begin| C[使用 co - begin 结构]
B --> |并行循环| D[使用并行循环]
B --> |launch - at - elaboration| E[声明时启动线程]
B --> |fork/join| F[使用 fork 和 join 操作]
B --> |隐式接收| G[绑定通道自动创建线程]
B --> |早期回复| H[使用早期回复机制]
C --> I[线程执行操作]
D --> I
E --> I
F --> I
G --> I
H --> I
I --> J[线程结束]
4. 线程的实现
4.1 线程实现的基本方式
并发程序的线程通常基于操作系统提供的一个或多个进程来实现。有两种极端情况:一种是为每个线程使用一个单独的操作系统进程;另一种是将程序的所有线程复用在一个进程上。在个人计算机上,单地址空间且进程成本相对较低时,每个线程一个进程的方式有时是可以接受的;在单处理器上的简单语言中,所有线程在一个进程上的方式也可能可行。通常,语言实现采用折中的方法,让大量线程运行在较少的进程之上。
4.2 不同实现方式的优缺点
- 每个线程一个进程 :问题在于进程(即使是轻量级进程)在许多操作系统中成本太高。因为它们在内核中实现,对其执行任何操作都需要系统调用,而且它们提供了大多数语言不需要但仍需付出代价的功能。
- 所有线程在一个进程上 :一是排除了在多处理器上的并行执行;二是如果当前运行的线程进行阻塞的系统调用(如等待 I/O),则程序的其他线程都无法运行,因为单个进程被操作系统挂起。
4.3 线程调度与实现步骤
在常见的并发两级组织(用户级线程基于内核级进程)中,系统的两个级别有相似的代码。用户级线程通常基于协程构建,将协程转换为线程可以通过以下三个步骤:
1.
隐藏转移参数
:实现一个调度器,当当前线程让出处理器时,选择下一个要运行的线程。
2.
实现抢占机制
:定期自动挂起当前线程,让其他线程有机会运行。
3.
共享数据结构
:允许描述线程集合的数据结构被多个操作系统进程共享,使线程可以在任何进程上运行。
4.4 单处理器调度
单处理器上的简单调度器使用的数据结构包括一个当前正在运行的线程、一个就绪列表(存储可运行但当前未运行的线程的上下文块)和与等待条件相关的队列(存储因同步而阻塞的线程的上下文块)。线程通过调用调度器来让出处理器,例如:
procedure reschedule
t : thread := dequeue(ready list)
transfer(t)
为了公平性,线程可以将自己的上下文块放入就绪列表,然后调用
reschedule
。例如:
procedure yield
enqueue(ready list, current thread)
reschedule
为了同步而阻塞时,线程将自己添加到与等待条件相关的队列中:
procedure sleep on(ref Q : queue of thread)
enqueue(Q, current thread)
reschedule
当一个运行的线程使某个条件为真时,它从相关队列中移除一个或多个线程,并将它们加入就绪列表。
在单处理器上,使用合作式多线程时,长时间运行的线程必须不时显式让出处理器,以允许其他线程运行。但这种方法可能导致一个编写不当的线程垄断系统,即使线程编写正确,由于不同线程让出时间不均匀,也会导致公平性不佳。
4.5 抢占式多线程
为了实现更公平的处理器复用,许多系统在语言实现中使用定时器信号进行抢占式多线程。当线程切换时,请求操作系统在未来指定时间向当前运行的进程发送信号。操作系统通过保存进程的上下文(寄存器和程序计数器)并将控制转移到语言运行时系统中预先指定的处理程序例程来传递信号。处理程序修改当前运行线程的状态,使其看起来像是执行了标准的
yield
例程调用,然后“返回”到
yield
中,将控制转移到其他线程。
然而,信号可能在任意时间到达,这会引入自愿调用调度器和抢占触发的自动调用之间的竞争。为了解决这个问题,线程包通常在调度器调用期间禁用信号传递。例如:
procedure yield
disable signals
enqueue(ready list, current thread)
reschedule
reenable signals
sleep_on
例程也需要调用者禁用和启用信号,以避免竞争条件。
4.6 多处理器调度
大多数并发语言允许线程并行运行。可以通过让进程共享就绪列表和相关数据结构(如条件队列),将抢占式线程包扩展到多个操作系统提供的进程上运行。如果这些进程在共享内存多处理器的不同处理器上运行,则多个线程可以同时运行;如果它们共享一个处理器,即使除一个进程外的所有进程都在操作系统中阻塞,程序也能继续前进。
但真正的并行或准并行会引入不同操作系统进程中调用之间的竞争,为了解决这些竞争,必须实现额外的同步,使不同进程中的调度器操作具有原子性。
4.7 线程实现的总结
线程的实现涉及多个方面,包括选择合适的进程和线程映射方式、实现调度器和同步机制等。不同的实现方式和机制各有优缺点,需要根据具体的应用场景和需求进行选择和优化。在单处理器和多处理器环境中,都需要考虑公平性、竞争条件和同步等问题,以确保并发程序的正确性和高效性。
4.8 线程实现的表格总结
| 实现方面 | 单处理器 | 多处理器 |
|---|---|---|
| 线程与进程映射 | 所有线程在一个进程或多个进程 | 多个进程运行在不同处理器或共享一个处理器 |
| 调度方式 | 合作式多线程或抢占式多线程 | 抢占式多线程,需解决进程间竞争 |
| 同步机制 | 忙等待或阻塞同步,禁用信号避免竞争 | 额外同步保证调度操作原子性 |
| 公平性 | 可能存在公平性问题,需线程主动让出 | 需考虑不同线程的优先级和缓存情况 |
5. 并发编程中的关键概念总结
5.1 核心概念对比表格
| 概念 | 定义 | 特点 | 适用场景 |
|---|---|---|---|
| 并发 | 两个或多个执行上下文可同时活跃的程序 | 协程不属于并发,同一时刻仅一个协程活跃 | 需要多任务处理,但不一定需要真正并行的场景 |
| 并行 | 在多个上下文中同时实际执行的并发程序 | 需要并行硬件支持 | 对计算速度要求高,可利用多核处理器的场景 |
| 线程 | 并发程序中的执行上下文 | 基于操作系统进程实现 | 多任务并发执行的基础单元 |
| 任务 | 线程必须执行的明确定义的工作单元 | 可共享任务包,动态添加新任务 | 多线程协作完成复杂工作的场景 |
| 共享内存通信 | 部分或全部程序变量可被多个线程访问 | 通过读写变量通信 | 数据共享频繁,线程间交互紧密的场景 |
| 消息传递通信 | 线程无共同状态,通过显式发送操作传输数据 | 通信更清晰,避免数据竞争 | 分布式系统,线程间独立性强的场景 |
| 忙等待同步 | 线程循环评估条件,直到条件为真 | 单处理器上无意义,浪费资源 | 对响应时间要求极高,且条件很快能满足的场景 |
| 阻塞同步 | 等待线程让出处理器,记录等待条件 | 更高效,避免资源浪费 | 大多数需要同步的场景 |
5.2 不同线程创建方式对比流程图
graph LR
A[线程创建方式] --> B{控制流界定}
B --> |是| C[co - begin、并行循环]
B --> |否| D{类似子例程声明}
D --> |是| E[launch - at - elaboration、fork/join、隐式接收、早期回复]
C --> F[适用于任务并行或数据并行]
E --> G[更灵活,可实现复杂并发模式]
F --> H[代码结构相对固定]
G --> I[可实现任意并发控制流]
6. 并发编程的挑战与应对策略
6.1 常见挑战列表
- 竞争条件 :多个线程同时访问和修改共享资源,导致数据不一致。例如在共享内存编程中,“接收”线程可能读取到“旧”值。
- 死锁 :两个或多个线程相互等待对方释放资源,导致所有线程都无法继续执行。
- 公平性问题 :某些线程长时间占用处理器,其他线程无法获得执行机会,影响程序整体性能。
- 资源管理困难 :线程的创建和销毁需要消耗系统资源,如果管理不当,会导致资源浪费或系统崩溃。
6.2 应对策略表格
| 挑战 | 应对策略 | 示例 |
|---|---|---|
| 竞争条件 | 使用同步机制,如锁、信号量等 | 在共享内存编程中,使用互斥锁保护共享变量 |
| 死锁 | 避免循环等待,合理分配资源 | 按照固定顺序获取资源,避免线程相互等待 |
| 公平性问题 | 采用抢占式调度,设置线程优先级 | 使用定时器信号进行抢占式多线程,为不同线程分配不同优先级 |
| 资源管理困难 | 使用线程池,避免频繁创建和销毁线程 | Java 中的 Executor 框架,管理线程池 |
6.3 同步机制的选择流程图
graph LR
A[同步需求] --> B{是否对响应时间要求极高}
B --> |是| C[忙等待同步]
B --> |否| D{是否在单处理器环境}
D --> |是| E{是否需要长时间等待}
E --> |是| F[阻塞同步]
E --> |否| G[忙等待同步(需谨慎)]
D --> |否| H{线程间独立性强弱}
H --> |强| I[消息传递同步]
H --> |弱| J[共享内存同步,使用锁机制]
7. 不同语言和库在并发编程中的应用总结
7.1 语言和库的特点表格
| 语言/库 | 特点 | 适用场景 | 示例代码 |
|---|---|---|---|
| POSIX pthreads | 基于共享内存和线程,Unix 标准 | 共享内存编程,多线程协作 | 使用 pthread_create 创建线程,pthread_join 等待线程结束 |
| PVM | 在异构分布式网络中创建和管理进程能力强 | 分布式系统,动态加入和退出计算节点 | 可创建不同类型机器的进程进行计算 |
| MPI | 对通信实现控制多,通信原语丰富 | 高性能计算,集体通信场景 | 使用 MPI_Send 和 MPI_Recv 进行消息传递 |
| Java | 提供线程类和线程池框架 | 跨平台的并发编程 |
class MyThread extends Thread {
public void run() {
System.out.println("Thread is running");
}
}
MyThread thread = new MyThread();
thread.start();
|C#|线程和线程池设施与 Java 类似|Windows 平台的并发编程|使用 Thread 类创建线程,ThreadPool 管理线程池|
|HPF|Fortran 90 的数据并行方言|科学计算,数据并行处理|
forall (i=1:n-1)
A(i) = B(i) + C(i)
end forall
7.2 语言和库的选择因素流程图
graph LR
A[并发编程需求] --> B{是否需要跨平台}
B --> |是| C{是否是科学计算场景}
C --> |是| D[Java、HPF]
C --> |否| E{是否是分布式系统}
E --> |是| F[PVM、MPI]
E --> |否| G[Java、C#]
B --> |否| H{是否是 Unix 系统}
H --> |是| I[POSIX pthreads]
H --> |否| J{是否是 Windows 系统}
J --> |是| K[C#]
J --> |否| L[根据具体需求选择]
8. 并发编程的未来趋势与展望
8.1 技术发展趋势列表
- 硬件发展推动 :随着多核处理器和分布式计算硬件的不断发展,并发编程将更加普及和重要。未来的硬件将提供更多的并行计算能力,需要更高效的并发编程模型来充分利用这些资源。
- 语言和框架的优化 :编程语言和框架将不断优化并发编程的支持,提供更简洁、安全和高效的并发编程接口。例如,Java 和 C# 等语言将继续改进线程池和并发库,减少开发人员的编程复杂度。
- 人工智能与并发编程的结合 :人工智能领域的大规模数据处理和模型训练需要强大的并发计算能力。并发编程将与人工智能技术深度结合,提高模型训练和推理的速度。
- 云原生并发编程 :云原生技术的发展使得应用程序可以在分布式云环境中运行。并发编程将适应云原生架构,实现更高效的资源利用和弹性伸缩。
8.2 对开发者的建议
- 持续学习 :并发编程是一个不断发展的领域,开发者需要持续学习新的技术和方法,跟上技术发展的步伐。
- 实践经验积累 :通过实际项目的开发,积累并发编程的实践经验,了解不同场景下的并发问题和解决方案。
- 关注硬件发展 :了解硬件技术的发展趋势,以便在并发编程中更好地利用硬件资源。
- 参与开源项目 :参与开源并发编程项目,与其他开发者交流和合作,共同推动并发编程技术的发展。
8.3 未来并发编程的可能架构流程图
graph LR
A[硬件层] --> B[多核处理器、分布式系统]
B --> C[操作系统层] --> D[支持多线程、多进程的操作系统]
D --> E[编程语言和框架层] --> F[优化的并发编程接口]
F --> G[应用层] --> H[人工智能、云原生应用]
H --> I[数据层] --> J[大规模数据存储和处理]
J --> K[反馈] --> A[硬件层]
并发编程是一个充满挑战和机遇的领域。通过深入理解并发编程的基础概念、掌握不同语言和库的使用、解决常见的并发问题,并关注未来的发展趋势,开发者可以编写出高效、正确的并发程序,满足不断增长的计算需求。希望本文能为大家在并发编程的学习和实践中提供有价值的参考。
超级会员免费看

6628

被折叠的 条评论
为什么被折叠?



