A.原子性
即一个操作或者多个操作,要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。原子性确保对共享资源的操作不会被其他线程干扰,从而避免数据不一致性(如竞态条件)
本质:原子性依赖于硬件和编译器的支持,例如CPU的原子指令(如x86的LOCK
前缀)或编译器生成的原子操作代码,确保操作在底层不可分割。
一个很经典的例子就是银行账户转账问题:
比如从账户A向账户B转1000元,那么必然包括2个操作:从账户A减去1000元,往账户B加上1000元。试想一下,如果这2个操作不具备原子性,会造成什么样的后果。假如从账户A减去1000元之后,操作突然中止。然后又从B取出了500元,取出500元之后,再执行往账户B加上1000元 的操作。这样就会导致账户A虽然减去了1000元,但是账户B没有收到这个转过来的1000元。
所以这2个操作必须要具备原子性才能保证不出现一些意外的问题。
原子性在多线程中的作用:
1.解决竞态条件
典型场景如共享计数器的自增操作i++
,实际包含读取→修改→写入三步。若多个线程同时执行,可能导致最终结果小于预期。
// 非原子操作:线程不安全
int counter = 0;
counter++; // 可能被其他线程打断
// 原子操作:线程安全
atomic_int atomic_counter = 0;
atomic_fetch_add(&atomic_counter, 1); // C11原子函数
在头文件<stdatomic.h>中定义 | | |
---|---|---|
C atomic_fetch_add(volatile A * obj,M arg); | Atomically replaces the value pointed by obj with the result of addition of arg to the old value of obj , and returns the value obj held previously. The operation is read-modify-write operation. The version orders memory accesses according to memory_order_seq_cst. | (自C11以来) |
C atomic_fetch_add_explicit(volatile A * obj,M arg,memory_order order); | This version orders memory accesses according to order . | (自C11以来) |
2.替代锁机制提升性能
原子操作通过硬件直接支持,无需锁的获取与释放,减少线程阻塞和上下文切换开销,适用于高并发场景。
// 使用互斥锁(低效)
pthread_mutex_lock(&lock);
counter++;
pthread_mutex_unlock(&lock);
// 使用原子操作(高效)
atomic_fetch_add(&atomic_counter, 1);
原子性的实现原理
1.硬件支持
原子指令:如x86的LOCK
前缀指令,锁定总线或缓存行,阻止其他核或线程访问同一内存地址。
内存屏障(Memory Barrier):防止编译器和CPU对指令重排序,确保操作顺序符合预期。
2.编译器与标准库支持
C11原子类型:如atomic_int
、atomic_bool
,通过<stdatomic.h>
提供原子操作函数(如atomic_load
、atomic_store
)。
原子函数实现:如atomic_fetch_add
内部调用原子指令,保证操作的不可分割性。
原子操作的应用场景:
1.简单计数器
多线程环境下统计任务完成数,使用原子自增操作替代锁,提升性能。
atomic_int task_count = 0;
void worker_thread() {
for (int i = 0; i < 1000; i++) {
atomic_fetch_add(&task_count, 1);
}
}
2.标志位控制
如线程间通信的状态标志,需原子读写以避免脏读。
atomic_bool is_ready = false; // 线程A设置标志
atomic_store(&is_ready, true); // 线程B读取标志
if (atomic_load(&is_ready)) {/* ... */}
3.无锁数据结构
如无锁队列的实现依赖原子比较交换(CAS)操作。
atomic_int* ptr = /* 共享指针 */;
int old_val = atomic_load(ptr);
do {
int new_val = compute_new_val(old_val);
} while (!atomic_compare_exchange_strong(ptr, &old_val, new_val)); // CAS操作
注意事项与局限性:
1.类型限制
原子操作仅支持基本数据类型(如int
、bool
),复杂结构需自行封装或使用锁。
2.操作复杂性
复合操作(如多变量更新)仍需锁机制,原子操作无法直接保证整体原子性。
3.内存顺序(Memory Order)
C11允许指定内存顺序(如memory_order_relaxed
、memory_order_seq_cst
),需根据场景平衡性能与正确性。
atomic_int a = 0;
atomic_store_explicit(&a, 42, memory_order_release); <em>// 指定内存顺序
4.平台差异性
不同CPU架构对原子指令的支持不同,需考虑可移植性。
与锁机制的对比:
特性 | 原子操作 | 锁机制 |
---|---|---|
性能 | 高(无阻塞) | 低(上下文切换开销) |
适用场景 | 简单变量操作(如计数、标志位) | 复杂逻辑或数据结构 |
实现复杂度 | 低(直接调用原子函数) | 高(需管理锁的获取与释放) |
扩展性 | 有限(仅支持基本类型) | 灵活(可保护任意代码块) |
要想在多线程环境下保证原子性,则可以通过锁、synchronized来确保。volatile是无法保证复合操作的原子性。
B.可见性
可见性问题(Visibility Problem) 是并发编程中的核心挑战之一,指当一个线程修改了共享变量的值后,其他线程无法立即感知到这一变化,导致程序行为不符合预期。这种现象的本质是数据的不一致性,通常由硬件架构(如CPU缓存)和编译器/运行时优化引起。
可见性问题的根源:
1. CPU缓存架构
现代CPU通过多级缓存(L1/L2/L3)加速数据访问,但每个核心的缓存是独立的。例如:
- 线程A在CPU核心1运行,修改了变量
x
,写入核心1的缓存。 - 线程B在CPU核心2运行,读取的
x
可能仍是核心2缓存中的旧值。
此时,线程B无法“看到”线程A的修改,导致数据不一致。
2. 编译器优化
编译器为提高性能,可能将变量缓存在寄存器中,而非每次从内存读取。例如:
// 线程A循环检查标志位
while (flag == 0) {/* 等待 */}
若flag未被声明为volatile,编译器可能优化为:
// 错误优化:将flag缓存在寄存器,导致死循环
register int cached_flag = flag;
while (cached_flag == 0) {}
将flag缓存在寄存器导致数据不可见,此时,即使内存中的flag
被其他线程或外部事件(如中断)修改,cached_flag
仍会保持初始值。循环条件while (cached_flag == 0)
会基于寄存器中的旧值判断,导致无法感知实际变化,从而陷入死循环。
3. 指令重排序
CPU或编译器可能对指令重新排序以提高效率,但这种优化可能破坏程序逻辑。例如:
// 初始代码
x = 1;ready = 1;
// 重排序后的实际执行顺序(假设x和ready无依赖)
ready = 1;x = 1;
若其他线程看到ready=1后立即读取x,可能读到未初始化的x=0。
可见性问题的表现场景:
1. 多线程共享变量
int counter = 0; // 共享变量
// 线程A
void thread_A() {
counter = 42; // 修改counter
}
// 线程B
void thread_B() {
printf("%d", counter); // 可能输出0(旧值)
}
问题:线程B可能因缓存或优化,读取到counter
的旧值。
2. 硬件/中断场景
int sensor_value; // 传感器数据,由中断更新
// 中断服务程序(ISR)
void ISR() {
sensor_value = read_sensor(); // 更新值
}
// 主程序循环读取
while (1) {
if (sensor_value > 100) { // 可能读取旧值
trigger_alarm();
}
}
问题:编译器可能将sensor_value
缓存在寄存器,导致主程序无法感知中断中的更新。
三、解决可见性问题的方法
1. 使用volatile
关键字
强制每次访问变量时都从内存读取或写入,禁用寄存器缓存。
volatile int sensor_value; // 确保主程序读取最新值
2. 内存屏障(Memory Barrier)
通过特殊指令(如mfence
)或原子操作,确保指令顺序和缓存一致性。
// 写操作后插入内存屏障
x = 1;
__sync_synchronize(); // GCC内置内存屏障
ready = 1;
3. 锁(Lock)和原子变量
- 锁(Mutex):通过锁的获取和释放隐式插入内存屏障。
pthread_mutex_lock(&lock);
counter++; // 临界区操作
pthread_mutex_unlock(&lock);
原子变量(C11 _Atomic
):
#include <stdatomic.h>
_Atomic int counter = 0;
atomic_store(&counter, 42); // 写操作保证可见性
关键注意事项:
1.volatile
≠ 线程安全volatile
仅解决可见性,不保证原子性。例如:
volatile int counter = 0;
counter++; // 非原子操作!可能被多个线程同时打断
此时仍需使用锁或原子操作。
2.不同语言/平台的差异
- Java的
volatile
还禁止指令重排序(通过内存屏障),而C/C++的volatile
不提供此保证。 - C11/C++11的原子变量(
_Atomic
、std::atomic
)是更现代的解决方案。
C.有序性
即程序执行的顺序按照代码的先后顺序执行。
举个简单的例子,看下面这段代码:
int i = 0;
boolean flag = false;
i = 1; //语句1
flag = true; //语句2
上面代码定义了一个int型变量,定义了一个boolean类型变量,然后分别对两个变量进行赋值操作。从代码顺序上看,语句1是在语句2前面的,那么JVM在真正执行这段代码的时候会保证语句1一定会在语句2前面执行吗?不一定,为什么呢?这里可能会发生指令重排序(Instruction Reorder)。
下面解释一下什么是指令重排序,一般来说,处理器为了提高程序运行效率,可能会对输入代码进行优化,它不保证程序中各个语句的执行先后顺序同代码中的顺序一致,但是它会保证程序最终执行结果和代码顺序执行的结果是一致的。
比如上面的代码中,语句1和语句2谁先执行对最终的程序结果并没有影响,那么就有可能在执行过程中,语句2先执行而语句1后执行。但是要注意,虽然处理器会对指令进行重排序,但是它会保证程序最终结果会和代码顺序执行结果相同,那么它靠什么保证的呢?再看下面一个例子:
int a = 10; //语句1
int r = 2; //语句2
a = a + 3; //语句3
r = a*a; //语句4
这段代码有4个语句,那么可能的一个执行顺序是:
语句2 -> 语句1 -> 语句3 -> 语句4
那么可不可能是这个执行顺序:
语句2 -> 语句1 -> 语句4 -> 语句3。
不可能,因为处理器在进行重排序时是会考虑指令之间的数据依赖性,如果一个指令Instruction 2必须用到Instruction 1的结果,那么处理器会保证Instruction 1会在Instruction 2之前执行。虽然重排序不会影响单个线程内程序执行的结果,但是多线程呢?下面看一个例子:
//线程1:
context = loadContext(); //语句1
inited = true; //语句2
//线程2:
while(!inited ){
sleep()
}
doSomethingwithconfig(context);
上面代码中,由于语句1和语句2没有数据依赖性,因此可能会被重排序。假如发生了重排序,在线程1执行过程中先执行语句2,而此是线程2会以为初始化工作已经完成,那么就会跳出while循环,去执行doSomethingwithconfig(context)方法,而此时context并没有被初始化,就会导致程序出错。
从上面可以看出,指令重排序不会影响单个线程的执行,但是会影响到线程并发执行的正确性。也就是说,要想并发程序正确地执行,必须要保证原子性、可见性以及有序性。只要有一个没有被保证,就有可能会导致程序运行不正确。
在Java内存模型中,允许编译器和处理器对指令进行重排序,但是重排序过程不会影响到单线程程序的执行,却会影响到多线程并发执行的正确性。
在Java里面,可以通过volatile关键字来保证一定的“有序性”。另外可以通过synchronized和Lock来保证有序性,很显然,synchronized和Lock保证每个时刻是有一个线程执行同步代码,相当于是让线程顺序执行同步代码,自然就保证了有序性。另外,Java内存模型具备一些先天的“有序性”,即不需要通过任何手段就能够得到保证的有序性,这个通常也称为 happens-before 原则。如果两个操作的执行次序无法从happens-before原则推导出来,那么它们就不能保证它们的有序性,虚拟机可以随意地对它们进行重排序。
文章评论