并发编程中的关键问题:临界区与锁
1. 全局整数递增的经典案例
在并发编程中,全局整数的递增操作看似简单,实则暗藏玄机。例如下面的代码:
static int i = 5;
[ ... ]
foo()
{
[ ... ]
i ++; // is this safe? yes, if truly atomic... but is it truly atomic??
}
从表面上看,
i++
操作似乎是原子的,但实际上并非如此。现代的硬件和软件(编译器和操作系统)为了提高性能,会进行各种优化,这使得
i++
操作可能会被拆分成多个机器指令。
1.1 影响
i++
原子性的因素
i++
是否为真正的原子操作取决于两个因素:
-
处理器的指令集架构(ISA)
:它决定了在运行时执行的机器指令。
-
编译器
:编译器的智能程度会影响它是否使用单条机器指令来执行整数递增操作。
如果 ISA 具备使用单条机器指令执行整数递增的能力,并且编译器能够利用这一特性,那么
i++
就是真正的原子操作,是安全的,不需要加锁;否则,就需要加锁。
1.2 验证
i++
的原子性
你可以通过以下步骤验证
i++
是否为单条机器指令:
1. 打开编译器探索网站:https://godbolt.org/ 。
2. 选择 C 作为编程语言。
3. 在左窗格中声明全局整数
i
并在函数中进行递增操作。
4. 在右窗格中使用合适的编译器和编译器选项进行编译。
5. 查看为 C 高级
i++;
语句生成的实际机器代码。如果是单条机器指令,则操作是安全的;否则,需要加锁。
1.3 编译器优化对
i++
原子性的影响
默认情况下,即使使用最新的稳定版 gcc 且不进行优化,x86_64 gcc 也会为
i++
生成多条指令。这对应于从内存读取到寄存器、递增操作以及将结果从寄存器存储回内存的过程,显然不是原子操作。然而,在编译器选项中添加
-O2
后,
i++
可能会变成单条机器指令,成为真正的原子操作。但这种情况难以提前预测,例如代码在低端 ARM(RISC)系统上运行时,
i++
可能需要多条机器指令。
1.4 现代语言的原子操作支持
现代语言提供了原生的原子操作符。对于 C/C++ 来说,从 2011 年的 ISO C++11 和 ISO C11 标准开始,就提供了现成的内置原子变量。现代的 glibc 也会使用这些原子变量。例如,在用户空间处理信号时,可以使用
volatile sig_atomic_t
数据类型来安全地访问和更新原子整数。
2. 锁的概念
2.1 锁的作用
在并发编程中,由于线程可以同时执行对共享可写数据(共享状态)进行操作的临界区代码,因此需要进行同步。为了消除并行性并序列化临界区代码,常用的技术是使用锁。锁的基本原理是保证在任何给定时间,只有一个线程可以“获取”或拥有锁。使用锁保护临界区代码可以确保代码以独占方式运行。
2.2 锁的工作原理
当多个线程竞争获取锁时,只有一个线程会成功,这个线程被称为“获胜者”或“锁的所有者”。获胜者线程将锁 API 视为非阻塞调用,继续愉快地、独占式地执行临界区代码。而其余的“失败者”线程将锁 API 视为阻塞调用,它们会等待锁的所有者执行解锁操作。一旦解锁,剩余的线程将再次竞争获取锁,直到所有线程依次获取锁。
2.3 锁的开销
虽然锁可以解决并发问题,但它会带来相当大的开销,因为它会消除并行性并序列化执行流程。可以将锁的作用类比为漏斗,漏斗的狭窄部分就像临界区,一次只能容纳一个线程,其他线程会被阻塞。另一个常见的类比是多条车道合并成一条繁忙且拥堵的车道,车辆(线程)被迫排队,失去了并行性。
2.4 避免不必要的锁
作为软件架构师,应尽量设计产品或项目,减少锁的使用。虽然在大多数实际项目中完全消除全局变量是不现实的,但优化和减少全局变量的使用是必要的。此外,新手程序员可能会天真地认为对共享可写数据对象的读取操作是完全安全的,不需要显式保护,但实际上这可能会导致脏读或撕裂读的问题。
2.5 实现原子性的方法
在典型的现代微处理器上,只有单条机器语言指令或对处理器总线宽度内对齐的原始数据类型的读写操作才能保证是原子的。在用户空间,很难保证代码的原子性,但可以通过构造一个使用 SCHED_FIFO 策略和实时优先级为 99 的用户线程来接近原子性。在内核空间,可以使用自旋锁来编写真正原子的代码。
3. 临界区的关键要点总结
3.1 临界区的定义
临界区是可以并行执行且对共享可写数据(共享状态)进行读写操作的代码路径。
3.2 临界区的保护需求
由于临界区对共享可写数据进行操作,因此需要保护:
-
并行性
:必须以独占、序列化的方式运行。
-
原子性
:在原子(中断)非阻塞上下文中运行时,要以不可分割的方式完成,无中断。
3.3 临界区的识别与保护
- 识别临界区 :仔细审查代码,确保不遗漏任何临界区。
- 保护临界区 :可以通过各种技术实现,常见的方法是加锁,也有无锁编程技术。
3.4 常见错误
- 只保护写操作 :不仅要保护对全局可写数据进行写操作的临界区,还要保护进行读操作的临界区,否则可能会出现撕裂读或脏读。
- 使用不同的锁 :对同一数据项必须使用相同的锁进行保护。
- 未保护临界区 :不保护临界区会导致数据竞争,结果会因运行时环境和时间而变化,这是一种难以发现、重现、确定根本原因和修复的错误。
3.5 例外情况
在以下情况下,无需显式保护:
-
处理局部变量
:局部变量分配在线程的私有栈上,本质上是安全的。
-
代码具有串行性
:在不能在其他上下文中运行的代码中处理共享可写数据,例如 LKM 的初始化和清理方法。
-
处理只读数据
:处理真正的常量只读数据时是安全的,但不要被 C 的
const
关键字误导。
3.6 锁的复杂性
加锁本身是复杂的,必须仔细思考、设计和实现,以避免死锁。
4. Linux 内核中的并发问题
4.1 识别内核代码中的临界区
对于内核或驱动开发者来说,识别内核代码中的临界区至关重要。以下情况可能会引发并发问题,从而产生临界区:
-
对称多处理器(SMP)系统的存在
-
可抢占内核的存在
-
阻塞 I/O
-
硬件中断(在 SMP 或单处理器系统上)
4.2 多核 SMP 系统与数据竞争
在多核 SMP 系统中,多个用户空间进程可以同时执行驱动的读取方法,同时对共享可写数据进行操作,这会导致数据竞争。例如,在一个虚构的驱动读取方法中,从时间 t2 到 t3 期间,驱动正在处理一些全局共享可写数据,这就是一个临界区。如果不进行保护,可能会导致数据损坏、应用程序崩溃等问题。
4.3 可抢占内核、阻塞 I/O 与数据竞争
4.3.1 可抢占内核情况
当内核配置为可抢占时,一个进程在执行驱动读取方法的临界区代码时,可能会被内核抢占并切换到另一个进程,这会导致数据竞争。这种情况在单处理器系统上也可能发生。
4.3.2 阻塞 I/O 情况
当进程在临界区中遇到阻塞调用时,会被挂起,等待事件发生。在这个过程中,如果另一个进程被调度运行,也会导致数据竞争。这种情况在单核心或多核系统上都可能出现。
综上所述,在并发编程中,无论是全局整数的递增操作,还是 Linux 内核中的并发问题,都需要仔细考虑临界区的识别和保护,合理使用锁等同步技术,以确保数据的完整性和程序的正确性。
下面是一个简单的 mermaid 流程图,展示了锁的竞争过程:
graph TD;
A[多个线程竞争锁] --> B{是否获取锁成功};
B -- 是 --> C[执行临界区代码];
C --> D[释放锁];
D --> E[其他线程继续竞争];
B -- 否 --> F[等待解锁];
F --> E;
下面是一个表格,总结了临界区保护的关键要点:
|要点|详情|
|----|----|
|临界区定义|可并行执行且对共享可写数据进行读写的代码路径|
|保护需求|防止并行性,在原子上下文无中断执行|
|识别方法|仔细审查代码|
|保护技术|加锁、无锁编程等|
|常见错误|只保护写操作、未使用相同锁、未保护临界区|
|例外情况|处理局部变量、串行代码、只读数据|
|锁的复杂性|需避免死锁|
5. 硬件中断与数据竞争
5.1 硬件中断对数据完整性的影响
在 Linux 内核环境中,硬件中断是不可忽视的并发因素。无论是在多核 SMP 系统还是单处理器(UP)系统中,硬件中断都可能随时发生。当一个进程正在执行临界区代码时,如果发生硬件中断,中断处理程序可能会访问或修改相同的共享可写数据,从而导致数据竞争。
例如,在一个驱动程序中,主程序可能正在更新一个全局计数器,而此时一个硬件设备触发了中断,中断处理程序也需要访问这个计数器。如果没有适当的保护机制,就可能出现数据不一致的情况。
5.2 应对硬件中断的数据竞争
为了应对硬件中断带来的数据竞争问题,需要在临界区代码中采取相应的保护措施。常见的方法是在进入临界区之前禁用中断,在离开临界区之后再启用中断。这样可以确保在临界区代码执行期间不会被硬件中断打断,从而保证数据的完整性。
以下是一个简单的示例代码,展示了如何在 C 语言中禁用和启用中断:
#include <linux/interrupt.h>
void critical_section_function() {
unsigned long flags;
// 禁用中断并保存当前中断状态
local_irq_save(flags);
// 临界区代码,对共享可写数据进行操作
// ...
// 启用中断并恢复之前的中断状态
local_irq_restore(flags);
}
6. 锁的类型与选择
6.1 常见的锁类型
在并发编程中,有多种类型的锁可供选择,不同类型的锁适用于不同的场景。常见的锁类型包括:
-
互斥锁(Mutex)
:最基本的锁类型,用于保护临界区,确保同一时间只有一个线程可以访问共享资源。
-
自旋锁(Spinlock)
:在等待锁的过程中,线程会不断地尝试获取锁,而不是进入睡眠状态。适用于临界区代码执行时间较短的场景。
-
读写锁(Read-Write Lock)
:允许多个线程同时进行读操作,但在写操作时需要独占访问。适用于读多写少的场景。
6.2 锁类型的选择原则
选择合适的锁类型需要考虑多个因素,如临界区代码的执行时间、并发访问的模式(读多写少还是写多读少)等。以下是一些选择锁类型的原则:
|锁类型|适用场景|
|----|----|
|互斥锁|临界区代码执行时间较长,且对共享资源的访问较为频繁|
|自旋锁|临界区代码执行时间较短,且线程竞争不太激烈|
|读写锁|读操作远远多于写操作的场景|
6.3 锁的性能考量
不同类型的锁在性能上也有所差异。自旋锁在等待锁的过程中会不断消耗 CPU 资源,因此如果临界区代码执行时间较长,使用自旋锁会导致 CPU 利用率下降。而互斥锁在等待锁时会使线程进入睡眠状态,避免了 CPU 资源的浪费,但线程的上下文切换也会带来一定的开销。
在实际应用中,需要根据具体的场景进行性能测试,选择最适合的锁类型。
7. 无锁编程技术
7.1 无锁编程的概念
无锁编程是一种不使用传统锁机制来实现并发控制的技术。它通过使用原子操作和内存屏障等技术,确保多个线程可以安全地访问共享资源,而不会出现数据竞争。
7.2 无锁编程的优势
与传统的锁机制相比,无锁编程具有以下优势:
-
更高的并发性能
:避免了锁的开销,减少了线程的上下文切换,从而提高了程序的并发性能。
-
避免死锁
:由于不使用锁,因此不存在死锁的问题。
7.3 无锁编程的实现方法
无锁编程通常使用原子操作和内存屏障来实现。原子操作是指不可分割的操作,在执行过程中不会被其他线程中断。内存屏障则用于确保内存操作的顺序,避免指令重排带来的数据不一致问题。
以下是一个简单的无锁队列的示例代码:
#include <stdatomic.h>
typedef struct Node {
int data;
struct Node *next;
} Node;
typedef struct Queue {
atomic_ptr(Node) head;
atomic_ptr(Node) tail;
} Queue;
void enqueue(Queue *q, int data) {
Node *new_node = (Node *)malloc(sizeof(Node));
new_node->data = data;
new_node->next = NULL;
Node *old_tail = atomic_exchange(&q->tail, new_node);
atomic_store(&old_tail->next, new_node);
}
int dequeue(Queue *q) {
Node *old_head = atomic_load(&q->head);
if (old_head == NULL) {
return -1; // 队列为空
}
Node *new_head = atomic_load(&old_head->next);
if (new_head == NULL) {
if (atomic_compare_exchange_strong(&q->head, &old_head, NULL)) {
free(old_head);
return -1; // 队列为空
} else {
new_head = atomic_load(&old_head->next);
}
}
atomic_store(&q->head, new_head);
int data = old_head->data;
free(old_head);
return data;
}
8. 总结与建议
8.1 并发编程的关键要点回顾
在并发编程中,需要重点关注以下几个方面:
-
临界区的识别与保护
:仔细审查代码,识别出所有可能的临界区,并使用合适的同步技术进行保护。
-
锁的合理使用
:根据临界区代码的特点和并发访问的模式,选择合适的锁类型,并注意锁的性能开销。
-
无锁编程的应用
:在合适的场景下,可以考虑使用无锁编程技术来提高程序的并发性能。
8.2 给开发者的建议
对于开发者来说,在进行并发编程时,应该养成良好的编程习惯:
-
编写清晰的代码
:确保代码的逻辑清晰,易于理解和维护。
-
进行充分的测试
:使用各种测试工具和方法,对并发程序进行充分的测试,确保程序的正确性和稳定性。
-
不断学习和实践
:并发编程是一个复杂的领域,需要不断学习和实践,掌握最新的技术和方法。
下面是一个 mermaid 流程图,展示了选择锁类型的决策过程:
graph TD;
A[确定临界区代码特点] --> B{临界区执行时间短?};
B -- 是 --> C{线程竞争激烈?};
C -- 否 --> D[选择自旋锁];
C -- 是 --> E{读多写少?};
E -- 是 --> F[选择读写锁];
E -- 否 --> G[选择互斥锁];
B -- 否 --> E;
通过以上的讨论,我们可以看到并发编程中临界区和锁的重要性。合理地识别和保护临界区,选择合适的锁类型,以及在必要时应用无锁编程技术,都可以帮助我们开发出高效、稳定的并发程序。
超级会员免费看
35

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



