并发编程中的锁算法与时间戳系统解析
在并发编程领域,锁算法和时间戳系统是实现线程同步和互斥的重要工具。本文将深入探讨几种经典的锁算法,包括Lamport的面包店算法,以及相关的时间戳系统,分析它们的原理、特性和局限性。
1. 过滤器锁算法的死锁特性
过滤器锁算法具有死锁-free的特性。在该算法中,若有两个线程A和B,只要A读取到
victim[j] = A
,它就会被阻塞;同理,B读取到
victim[j] = B
时也会被阻塞。但
victim
字段是固定的,不可能同时等于A和B,所以必然有一个线程能进入
j + 1
级别,从而使阻塞线程数量减少到
k - 1
,这与归纳假设矛盾,因此过滤器锁算法是死锁-free的。
2. 锁算法的公平性
锁算法的饥饿-free特性保证了每个调用
lock()
方法的线程最终都能进入临界区,但它并未对进入的时间做出保证。为了实现更公平的调度,我们将
lock()
方法分为两个部分:
-
门道部分
:执行区间
DA
由有限个步骤组成,要求该部分必须在有限步骤内完成,这一要求被称为有界无等待进展属性。
-
等待部分
:执行区间
WA
可能需要无限个步骤。
公平性的定义为:若线程A在门道部分完成的时间早于线程B开始门道部分的时间,那么A不会被B超越,即若
DjA → DkB
,则
CSjA → CSkB
。
3. Lamport的面包店算法
面包店算法通过模拟面包店中的号码分发机来实现先来先服务的特性。每个线程在门道部分获取一个号码,然后等待直到没有比它号码更早的线程试图进入临界区。
以下是面包店算法的Java实现:
class Bakery implements Lock {
boolean[] flag;
Label[] label;
public Bakery (int n) {
flag = new boolean[n];
label = new Label[n];
for (int i = 0; i < n; i++) {
flag[i] = false; label[i] = 0;
}
}
public void lock() {
int i = ThreadID.get();
flag[i] = true;
label[i] = max(label[0], ...,label[n-1]) + 1;
while ((∃k != i)(flag[k] && (label[k],k) << (label[i],i))) {};
}
public void unlock() {
flag[ThreadID.get()] = false;
}
}
在这个算法中,
flag[A]
是一个布尔标志,用于表示线程A是否想要进入临界区;
label[A]
是一个整数,表示线程A进入面包店的相对顺序。
每次线程获取锁时,会分两步生成一个新的
label[]
:
1. 以任意顺序读取其他所有线程的
label
。
2. 逐个读取其他线程的
label
,并生成一个比读取到的最大
label
大1的新
label
。
从设置
flag
(第13行)到写入新
label[]
(第14行)的代码被称为门道部分,它确定了该线程相对于其他试图获取锁的线程的顺序。若两个线程同时执行门道部分,它们可能读取到相同的最大
label
并选择相同的新
label
。为了打破这种对称性,算法使用了字典序
<<
来比较
label[]
和线程ID的对:
(label[i],i) << (label[j],j)
当且仅当
label[i] < label[j]
或
label[i] = label[j]
且
i < j
。
在等待部分(第15行),线程会不断以任意顺序重新读取
label
,直到确定没有标志为真且字典序更小的
label/id
对。
面包店算法具有以下特性:
-
死锁-free
:存在一个等待线程A,其
(label[A],A)
对是唯一最小的,该线程不会等待其他线程。
-
先来先服务
:若A的门道部分先于B,即
DA → DB
,则A的
label
更小,因为
writeA(label[A]) → readB(label[A]) → writeB(label[B]) → readB(flag[A])
,所以当
flag[A]
为真时,B会被锁定在外。
-
互斥性
:假设存在两个线程A和B同时在临界区,若
(label[A],A) << (label[B],B)
,当B成功通过等待部分的测试时,它必然读取到
flag[A]
为假或
(label[B],B) << (label[A],A)
。但由于线程ID固定且
label[]
值严格递增,B必然看到
flag[A]
为假,这与假设矛盾。
4. 有界时间戳系统
面包店算法中的
label
会无限制增长,在长期运行的系统中可能会出现溢出问题。若线程的
label
字段从一个大数悄然回滚到零,先来先服务的特性将不再成立。
时间戳系统在并发编程中用于确定线程的顺序。在面包店算法中,线程需要具备两个能力:
-
扫描
:读取其他线程的时间戳。
-
标记
:为自己分配一个较晚的时间戳。
以下是一个Java接口,用于表示时间戳系统:
public interface Timestamp {
boolean compare(Timestamp);
}
public interface TimestampSystem {
public Timestamp[] scan();
public void label(Timestamp timestamp, int i);
}
我们可以将可能的时间戳范围看作有向图(优先级图)的节点。节点a到节点b的边表示a是比b更晚的时间戳。该顺序具有非自反性和反对称性,但不要求具有传递性。
为了实现有界时间戳系统,我们可以使用优先级图。例如,对于两个线程的情况,我们可以使用具有三个节点(标记为0、1、2)的优先级图
T2
,其边定义了节点之间的顺序关系:0 < 1,1 < 2,2 < 0。在这个系统中,两个线程的令牌总是位于相邻节点,边的方向表示它们的相对顺序。
为了处理更多线程,我们引入图乘法和递归定义的图
Tk
:
-
T1
是单个节点。
-
T2
是前面定义的三节点图。
- 对于
k > 2
,
Tk = T2 ◦ Tk−1
。
优先级图
Tn
是n线程有界顺序时间戳系统的基础。我们可以使用三进制表示法来“寻址”
Tn
图中的任何节点。理解n线程标记算法的关键在于,被令牌覆盖的节点永远不会形成循环。
以三个线程为例,当A调用
label()
方法时,如果另外两个线程的令牌都在同一个
T2
子图中,A会移动到下一个更高的
T2
子图中的节点,该子图的节点支配前一个
T2
子图。
5. 内存位置数量的下限
面包店算法简洁、优雅且公平,但它需要读写n个不同的位置,其中n是最大并发线程数,这使得它在实际应用中不太实用。
可以证明,任何死锁-free的锁算法在最坏情况下至少需要分配并读写n个不同的位置。以三个线程的情况为例,假设存在一个仅使用两个位置的死锁-free锁算法。初始状态下,没有线程在临界区或试图进入。若单独运行任何一个线程,它在进入临界区之前必须至少写入一个位置,否则会创建一个不一致的状态。
我们定义了覆盖状态:对于一个锁对象,存在至少一个线程即将写入每个共享位置,但锁对象的位置看起来就像临界区是空的。
通过反证法可以证明,任何通过读写内存解决三个线程死锁-free互斥问题的锁算法,必须使用至少三个不同的内存位置。同样的论证可以扩展到n线程的情况,这表明Peterson和面包店锁在一定常数因子内是最优的,但由于每个锁需要分配n个位置,它们在实际中并不实用。
这个证明揭示了读写操作的固有局限性:线程写入某个位置的信息可能会在其他线程看到之前被覆盖。在后续设计其他算法时,我们需要牢记这一局限性。现代机器架构提供了专门的指令来克服读写指令的“覆盖”限制,使得可以使用仅常数数量的内存位置来实现n线程锁,但有效利用这些指令来解决互斥问题并非易事。
综上所述,锁算法和时间戳系统在并发编程中起着至关重要的作用。不同的算法和系统各有优缺点,我们需要根据具体的应用场景选择合适的方法,并在设计新算法时充分考虑读写操作的局限性。未来,随着硬件技术的发展,我们有望找到更高效、更实用的并发编程解决方案。
并发编程中的锁算法与时间戳系统解析
6. 算法特性总结
为了更清晰地对比不同算法的特性,我们将面包店算法和过滤器锁算法的关键特性总结如下表:
| 算法 | 死锁特性 | 公平性 | 互斥性 | 内存位置需求 |
| — | — | — | — | — |
| 面包店算法 | 死锁-free | 先来先服务 | 满足 | 至少n个不同位置 |
| 过滤器锁算法 | 死锁-free | 未提及 | 未提及 | 未提及 |
从这个表格中,我们可以直观地看到面包店算法在公平性和互斥性方面表现出色,但内存位置需求较高,这限制了它在实际中的应用。而过滤器锁算法虽然保证了死锁-free,但在公平性和互斥性方面没有明确的结论,其内存位置需求也未提及。
7. 时间戳系统的优先级图分析
优先级图在有界时间戳系统中起着核心作用。下面我们通过mermaid格式的流程图来展示
T2
和
T3
优先级图的结构和线程令牌的移动逻辑。
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(节点0):::process --> B(节点1):::process
B --> C(节点2):::process
C --> A
subgraph T2
A
B
C
end
D(节点00):::process --> E(节点01):::process
E --> F(节点02):::process
F --> D
G(节点10):::process --> H(节点11):::process
H --> I(节点12):::process
I --> G
J(节点20):::process --> K(节点21):::process
K --> L(节点22):::process
L --> J
subgraph T3
D
E
F
G
H
I
J
K
L
end
style T2 fill:#ffffff,stroke:#000000,stroke-width:2px
style T3 fill:#ffffff,stroke:#000000,stroke-width:2px
在
T2
优先级图中,节点0、1、2形成一个循环,两个线程的令牌总是位于相邻节点,边的方向表示它们的相对顺序。例如,若线程A的令牌在节点0,线程B的令牌在节点1,则B具有较晚的时间戳。当A需要更新时间戳时,它会从节点0跳到节点2,以“跨越”B的节点。
在
T3
优先级图中,每个
T2
子图都有类似的结构。当有三个线程时,若两个线程的令牌在同一个
T2
子图中,另一个线程会移动到下一个更高的
T2
子图中的节点,以保证其时间戳更晚。
8. 锁算法内存位置下限证明的详细步骤
下面我们详细梳理三个线程情况下,证明死锁-free锁算法至少需要三个不同内存位置的步骤:
1.
初始状态分析
:初始状态下,没有线程在临界区或试图进入。若单独运行任何一个线程,它在进入临界区之前必须至少写入一个位置,否则会创建一个不一致的状态。
2.
确定线程写入位置
:
- 考虑线程B,让它运行通过临界区三次。由于只有两个位置,B在试图进入临界区时“首次写入”的位置必然有两次相同,我们将这个位置称为
LB
。
- 让B运行直到它准备首次写入
LB
。此时若运行线程A,A会进入临界区,因为B还未写入任何内容。A在进入临界区之前必须写入
LA
,否则会导致不一致状态。
3.
调整线程状态到覆盖状态
:
- 让A运行直到它准备写入
LA
。此时状态不是覆盖状态,因为A可能已经向
LB
写入了信息,表明它试图进入临界区。
- 让B运行,覆盖A可能写入
LB
的任何值,进入并离开临界区最多三次,然后在第二次写入
LB
之前停止。此时,A准备写入
LA
,B准备写入
LB
,且位置状态与没有线程试图进入或在临界区一致,这就是覆盖状态。
4.
得出矛盾结论
:
- 让C单独运行,由于算法是死锁-free的,C最终会进入临界区。
- 然后让A和B分别更新它们覆盖的位置,得到状态
s′
。在这个状态下,没有线程能判断C是否在临界区,这是一个不一致的状态,与假设矛盾。
9. 总结与展望
在并发编程中,锁算法和时间戳系统是实现线程同步和互斥的重要手段。面包店算法虽然具有公平性和互斥性等优点,但由于其对内存位置的高需求,在实际应用中受到限制。而有界时间戳系统通过优先级图的设计,为解决面包店算法中
label
溢出问题提供了一种思路。
同时,我们证明了任何死锁-free的锁算法在最坏情况下至少需要分配并读写n个不同的位置,这揭示了读写操作的固有局限性。现代机器架构提供的专门指令为克服这些局限性提供了可能,但如何有效利用这些指令来设计高效、实用的并发算法仍然是一个挑战。
未来,随着硬件技术的不断发展和算法研究的深入,我们有望找到更优化的解决方案,以满足不同并发场景的需求。例如,进一步研究如何在保证算法正确性的前提下,减少对内存位置的依赖,或者开发更高效的时间戳系统,以提高并发程序的性能和可扩展性。
总之,理解和掌握锁算法和时间戳系统的原理和特性,对于设计高质量的并发程序至关重要。我们需要根据具体的应用场景,权衡不同算法的优缺点,选择最合适的解决方案。
299



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



