并发编程中的共享对象、同步与协调协议
1. 并发编程的挑战与共享对象
在当今的编程领域,并发架构虽有发展,但尚未能像顺序架构那样提供高效的抽象。程序员在进行并发编程时需格外谨慎。在并发编程中,共享对象和编程工具至关重要。例如,自旋锁能说明竞争问题,链表能体现锁在数据结构设计中的作用,这些问题都对程序性能有重要影响。
在编程语言的选择上,这里采用Java语言。当然,也有其他合适的语言,但选择Java有诸多原因,具体可在闲暇时深入探讨。同时,还会在附录中说明Java中的概念如何在其他流行语言或库中表达,并提供多处理器硬件的入门知识。为保证内容的时效性,避免给出具体的程序和算法性能数字,而是注重一般性趋势。
2. 共享对象与同步实例:寻找质数问题
假设你刚入职,老板要求你使用支持10个并发线程的并行机器找出1到$10^{10}$之间的所有质数。由于机器按分钟计费,你希望程序运行时间尽可能短。
2.1 平均分配输入域的尝试
首先,你可能会考虑给每个线程分配相等的输入域,即每个线程检查$10^{9}$个数,代码如下:
void primePrint {
int i = ThreadID.get(); // thread IDs are in {0..9}
int block = power(10, 9);
for (int j = (i * block) + 1; j <= (i + 1)* block; j++) {
if (isPrime(j))
print(j);
}
}
然而,这种方法存在问题。因为质数分布不均匀,1到$10^{9}$之间的质数比$9\times10^{9}$到$10^{10}$之间的多,而且测试大数字是否为质数通常比小数字花费更多时间。所以,不能保证工作能在各线程间平均分配,也不清楚哪个线程的工作量最大。
2.2 使用共享计数器平衡工作量
更有效的方法是为每个线程每次分配一个整数。为此,引入一个共享计数器,它封装一个整数值,并提供
getAndIncrement()
方法来递增其值并返回先前的值。代码如下:
Counter counter = new Counter(1); // shared by all threads
void primePrint {
long i = 0;
long limit = power(10, 10);
while (i < limit) { // loop until all numbers taken
i = counter.getAndIncrement(); // take next untaken number
if (isPrime(i))
print(i);
}
}
但简单的计数器实现存在问题。以下是一个Java的简单计数器实现:
public class Counter {
private long value; // counter starts at one
public Counter(int i) { // constructor initializes counter
value = i;
}
public long getAndIncrement() { // increment, returning prior value
return value++;
}
}
当多个线程同时调用
getAndIncrement()
方法时,可能会出现问题。因为
return value++;
实际上是以下更复杂代码的缩写:
long temp = value;
value = temp + 1;
return temp;
多个线程可能会同时读取
value
的值,导致返回相同的值,甚至可能出现计数器值回退的情况。
解决这个问题的方法有两种。一是利用现代多处理器硬件提供的特殊读 - 修改 - 写指令,使线程能在一个原子硬件步骤中完成读、修改和写操作;二是在软件层面保证同一时间只有一个线程执行读 - 写序列,这就是互斥问题,是多处理器编程中的经典协调问题。虽然实际中可能会使用库来解决互斥问题,但理解如何从基础实现互斥是理解并发计算的必要条件。
3. 协调问题的寓言:Alice和Bob的院子
为了更好地理解并发编程中的协调问题,我们来看一个寓言。Alice和Bob是邻居,他们共享一个院子。Alice有一只猫,Bob有一只狗,这两只宠物相处不融洽。他们需要协调,确保两只宠物不会同时在院子里。
3.1 沟通方式的尝试
- 呼喊和打电话 :Alice不能简单地从窗户看Bob的狗是否在院子里,呼喊或打电话的方式也不可靠,因为Bob可能听不到,比如他可能在看电视、约会或购物等。这种沟通方式属于瞬态通信,需要双方同时参与。
- 啤酒罐和绳子 :Alice想出一个办法,在Bob的窗台上放一些空啤酒罐,用绳子连接到自己家。当她想给Bob信号时,拉动绳子打翻一个罐子,Bob看到后重置罐子。但这种方法也有缺陷,因为Alice能放的罐子数量有限,如果Bob长时间不在,Alice可能会用完罐子。这种方式类似于并发系统中的中断机制,线程A通过设置一个B定期检查的位置上的位来中断线程B,B注意到后做出反应并重置该位。
3.2 旗帜协议
Alice和Bob尝试使用旗帜协议。
-
Alice的操作流程
:
1. 升起自己的旗帜。
2. 当Bob的旗帜降下时,放出她的猫。
3. 猫回来后,降下自己的旗帜。
-
Bob的操作流程
:
1. 升起自己的旗帜。
2. 当Alice的旗帜升起时:
a. 降下自己的旗帜。
b. 等待直到Alice的旗帜降下。
c. 升起自己的旗帜。
3. 当自己的旗帜升起且Alice的旗帜降下时,放出他的狗。
4. 狗回来后,降下自己的旗帜。
从直观上看,这个协议是有效的。因为如果Alice和Bob都升起自己的旗帜并查看对方的旗帜,至少有一方会看到对方的旗帜升起而不会让宠物进入院子。通过反证法可以证明两只宠物不会同时在院子里。假设两只宠物能同时在院子里,考虑他们最后一次升起旗帜并查看对方旗帜的情况。当Alice最后查看时,她的旗帜已完全升起,若她看到Bob的旗帜,就不会放出猫,所以Bob在Alice开始查看前一定没有完成升起旗帜。那么当Bob最后查看时,一定是在Alice开始查看之后,他会看到Alice的旗帜升起而不会放出狗,这就产生了矛盾。
4. 互斥协议的性质
为了证明旗帜协议是Alice和Bob问题的正确解决方案,需要了解一个解决方案应具备的性质。
|性质|描述|旗帜协议表现|
| ---- | ---- | ---- |
|互斥性|两只宠物不会同时在院子里|已证明满足|
|无死锁性|若一只宠物想进入院子,最终能成功;若两只宠物都想进入,至少有一只最终能成功|满足,当两只宠物都想使用院子时,Bob会注意到Alice的旗帜升起,降下自己的旗帜让Alice的猫进入院子|
|无饥饿性|若一只宠物想进入院子,最终一定能成功|表现不佳,当Alice和Bob有冲突时,Bob总是让着Alice,可能导致Bob的狗一直无法进入院子|
|容错性|考虑到一方延迟的情况,协议能否正常工作|存在问题,若Alice升起旗帜后因突发疾病住院,Bob的狗要等Alice回来降下旗帜才能进入院子|
5. 通信方式与协调协议的启示
并发系统中有两种自然的通信方式:
-
瞬态通信
:需要双方同时参与,如呼喊、手势或打电话。
-
持久通信
:发送者和接收者可以在不同时间参与,如写信、发邮件或留便条。
互斥问题需要持久通信,因为瞬态通信在一方无法响应时无法提供准确信息。虽然啤酒罐和绳子协议看似牵强,但它对应着并发系统中的中断机制。中断虽不能解决互斥问题,但很有用,例如Java语言中的
wait()
和
notifyAll()
调用就基于中断通信。
综上所述,在并发编程中,理解共享对象、同步机制以及协调协议的性质和实现方法至关重要。通过实际例子和寓言故事,我们能更深入地理解这些概念,为编写高效、可靠的并发程序打下基础。
并发编程中的共享对象、同步与协调协议
6. 从寓言到并发编程的映射
将Alice和Bob的院子寓言映射到并发编程中,能让我们更清晰地理解并发编程中的各种概念。在这个寓言里,院子就相当于共享资源,猫和狗代表不同的线程,而协调协议则对应着并发编程中的同步机制。
例如,旗帜协议中的升起和降下旗帜操作,类似于并发编程中线程对共享资源的加锁和解锁操作。当线程想要访问共享资源时,就像Alice或Bob想要让宠物进入院子,需要先进行某种操作(升起旗帜)来表明自己的意图,然后检查其他线程的状态(查看对方旗帜),只有在满足一定条件时才能访问资源(放出宠物)。
下面我们用一个简单的流程图来展示这个映射关系:
graph LR
classDef startend fill:#F5EBFF,stroke:#BE8FED,stroke-width:2px;
classDef process fill:#E5F6FF,stroke:#73A6FF,stroke-width:2px;
classDef decision fill:#FFF6CC,stroke:#FFBC52,stroke-width:2px;
A([线程想要访问共享资源]):::startend --> B(加锁操作,表明意图):::process
B --> C{检查其他线程状态}:::decision
C -->|满足条件| D(访问共享资源):::process
C -->|不满足条件| E(等待):::process
E --> B
D --> F(解锁操作):::process
F --> G([完成访问]):::startend
H([Alice或Bob想让宠物进院子]):::startend --> I(升起旗帜,表明意图):::process
I --> J{查看对方旗帜状态}:::decision
J -->|对方旗帜降下| K(放出宠物):::process
J -->|对方旗帜升起| L(等待):::process
L --> I
K --> M(降下旗帜):::process
M --> N([宠物回到屋内]):::startend
7. 并发编程中协调协议的设计原则
从Alice和Bob的协调协议中,我们可以总结出并发编程中协调协议的一些设计原则。
7.1 正确性原则
协议必须保证共享资源的互斥访问,就像旗帜协议保证猫和狗不会同时在院子里一样。在并发编程中,这意味着要确保同一时间只有一个线程能访问关键资源,避免数据竞争和不一致的问题。
7.2 活性原则
协议应满足无死锁性和无饥饿性。无死锁性确保线程不会因为相互等待而陷入无限等待的状态,就像Alice和Bob的协议保证至少有一只宠物能进入院子。无饥饿性则保证每个线程最终都能获得访问资源的机会,避免某些线程一直被阻塞。
7.3 容错性原则
考虑到线程可能会出现延迟或故障的情况,协议应具有一定的容错能力。例如,在Alice突发疾病住院的情况下,Bob的狗不应被无限期地阻止进入院子。在并发编程中,这可能意味着要设置超时机制或采用其他策略来处理异常情况。
7.4 通信有效性原则
选择合适的通信方式对于协议的有效性至关重要。互斥问题需要持久通信,因为瞬态通信在一方无法响应时无法提供准确信息。在并发编程中,要根据具体情况选择合适的通信机制,确保线程之间能够有效地交换信息。
下面用表格总结这些原则:
|设计原则|描述|示例|
| ---- | ---- | ---- |
|正确性原则|保证共享资源的互斥访问|旗帜协议保证猫和狗不同时在院子|
|活性原则|满足无死锁性和无饥饿性|Alice和Bob的协议保证至少一只宠物能进院子|
|容错性原则|具有处理线程延迟或故障的能力|设置超时机制处理异常情况|
|通信有效性原则|选择合适的通信方式|互斥问题选择持久通信|
8. 并发编程中的常见问题及解决方案
在并发编程中,除了互斥问题,还有一些常见的问题,下面我们来详细介绍并给出相应的解决方案。
8.1 死锁问题
死锁是指两个或多个线程相互等待对方释放资源,从而导致所有线程都无法继续执行的情况。例如,线程A持有资源X并等待资源Y,而线程B持有资源Y并等待资源X,就会形成死锁。
解决方案:
-
资源排序
:为资源分配一个全局的顺序,所有线程按照这个顺序请求资源,避免循环等待。
-
超时机制
:设置线程请求资源的超时时间,如果在规定时间内未能获得资源,则释放已持有的资源并重新尝试。
8.2 数据竞争问题
数据竞争是指多个线程同时访问和修改共享数据,导致数据不一致的问题。就像在寻找质数问题中,多个线程同时访问计数器可能会导致返回相同的值。
解决方案:
-
互斥锁
:使用互斥锁来保证同一时间只有一个线程能访问共享数据,例如Java中的
synchronized
关键字。
-
原子操作
:利用硬件提供的原子指令,确保对共享数据的操作是原子的,避免数据竞争。
8.3 饥饿问题
饥饿是指某个线程由于其他线程的竞争而长时间无法获得资源的情况。在Alice和Bob的协议中,Bob的狗可能会因为Alice的猫一直占用院子而无法进入。
解决方案:
-
公平锁
:使用公平锁机制,确保线程按照请求资源的顺序获得资源,避免某些线程一直被阻塞。
-
动态调整策略
:根据线程的等待时间或其他因素,动态调整资源分配策略,保证每个线程都有机会获得资源。
下面用列表展示这些问题和解决方案:
-
死锁问题
- 原因:线程相互等待对方释放资源
- 解决方案:资源排序、超时机制
-
数据竞争问题
- 原因:多个线程同时访问和修改共享数据
- 解决方案:互斥锁、原子操作
-
饥饿问题
- 原因:某个线程被其他线程长时间竞争资源
- 解决方案:公平锁、动态调整策略
9. 总结
并发编程是一个充满挑战的领域,涉及到共享对象、同步机制和协调协议等多个方面。通过对寻找质数问题和Alice与Bob院子寓言的分析,我们深入理解了并发编程中的互斥问题、通信方式以及协调协议的性质和设计原则。
在实际编程中,我们要注意选择合适的编程语言和工具,理解各种并发问题的本质,并根据具体情况选择合适的解决方案。同时,要注重代码的正确性、活性、容错性和通信有效性,确保程序在并发环境下能够高效、可靠地运行。
希望通过本文的介绍,能帮助读者更好地掌握并发编程的核心概念和技术,为开发高质量的并发应用程序打下坚实的基础。
56



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



