并发编程三大特性——原子性、可见性、有序性

2025年5月19日 52点热度 1人点赞 0条评论

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_intatomic_bool,通过<stdatomic.h>提供原子操作函数(如atomic_loadatomic_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.类型限制​
原子操作仅支持基本数据类型(如intbool),复杂结构需自行封装或使用锁。

​2.操作复杂性​
复合操作(如多变量更新)仍需锁机制,原子操作无法直接保证整体原子性。

​3.内存顺序(Memory Order)​
C11允许指定内存顺序(如memory_order_relaxedmemory_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的原子变量(_Atomicstd::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原则推导出来,那么它们就不能保证它们的有序性,虚拟机可以随意地对它们进行重排序。

MuWinds

这个人很懒,什么都没留下

文章评论