目录
- [1. 什么是原子操作?](#1. 什么是原子操作?)
-
- [1.1 原子操作相关函数](#1.1 原子操作相关函数)
- [2. 原子变量](#2. 原子变量)
- [3. 原子变量与普通变量的区别](#3. 原子变量与普通变量的区别)
- [4. 原子锁的实现](#4. 原子锁的实现)
- [5. 实例代码](#5. 实例代码)
1. 什么是原子操作?
原子操作是计算机科学中的概念,指的是在执行期间不能被中断的一组操作。在多线程环境中,确保原子操作的执行是不可分割的,要么完全执行,要么完全不执行。这种特性使得在并发编程中更容易管理共享资源,避免竞态条件和数据不一致。
原子操作的概念和实现起源于计算机科学和并发编程的领域。在多核处理器和多线程应用程序普及之前,原子操作并不是那么重要。然而,随着硬件的发展和计算机体系结构的演变,原子操作成为处理并发和保证数据一致性的关键工具。
C语言引入原子操作的头文件 <stdatomic.h>
是在 C11 标准中的一项重要改进。它提供了一套通用的原子类型和原子操作函数,为程序员提供了更直接、更高效的多线程编程工具。在此之前,一些编译器提供了自己的原子操作扩展,但它们通常是非标准的。
在C语言中,原子操作通常通过 <stdatomic.h>
头文件中定义的原子操作函数来实现。这些函数提供了一组能够确保原子性的操作,如比较交换、加法、减法等。
c
#include <stdatomic.h>
atomic_int my_atomic_variable = ATOMIC_VAR_INIT(0);
void increment_atomic_variable() {
atomic_fetch_add(&my_atomic_variable, 1);
}
1.1 原子操作相关函数
在 C 语言中,<stdatomic.h>
提供了一系列原子操作函数,用于对原子变量执行操作。这些原子操作函数包括带有 __
前缀和不带有 __
前缀的两种版本。下面是一些常见的原子操作函数及其区别的示例:
- 带有
__
前缀的原子操作函数:
__atomic_load
/ __atomic_store
:
- 作用: 分别用于加载和存储原子变量的值。
__atomic_load
用于加载,返回原子变量的当前值;__atomic_store
用于存储,将原子变量设置为指定的值。
c
#include <stdatomic.h>
atomic_int my_atomic_variable = ATOMIC_VAR_INIT(42);
int loaded_value = __atomic_load(&my_atomic_variable, __ATOMIC_RELAXED);
__atomic_store(&my_atomic_variable, 100, __ATOMIC_RELAXED);
__atomic_exchange
:
- 作用: 原子地将原子变量的值与给定值进行交换。
- 返回交换前的原子变量值。
c
int previous_value = __atomic_exchange(&my_atomic_variable, 55, __ATOMIC_RELAXED);
- 不带有
__
前缀的原子操作函数:
atomic_load
/ atomic_store
:
- 作用: 分别用于加载和存储原子变量的值。
atomic_load
用于加载,返回原子变量的当前值;atomic_store
用于存储,将原子变量设置为指定的值。
c
#include <stdatomic.h>
atomic_int my_atomic_variable = ATOMIC_VAR_INIT(42);
int loaded_value = atomic_load(&my_atomic_variable);
atomic_store(&my_atomic_variable, 100);
atomic_exchange
:
- 作用: 原子地将原子变量的值与给定值进行交换。
- 返回交换前的原子变量值。
c
int previous_value = atomic_exchange(&my_atomic_variable, 55);
-
命名方式: 带有
__
前缀的函数更加底层,使用的是原子类型的操作,更接近硬件级别的实现。不带有__
前缀的函数是对带有__
前缀的函数的封装,更易用但可能效率稍低。 -
可移植性: 不带有
__
前缀的函数更符合 C 标准,因此具有更好的可移植性。带有__
前缀的函数可能依赖于具体的编译器实现。
在实际使用中,可以根据具体需求和平台选择适合的函数。通常情况下,不带有 __
前缀的函数已经足够满足大多数需求,而且更具可移植性。
2. 原子变量
原子变量是一种特殊的变量类型,它在多线程环境中能够进行原子操作。C11 标准引入了 <stdatomic.h>
头文件,通过 atomic
关键字,我们可以声明原子变量。原子变量通过原子操作函数确保了对它们的操作是原子的,防止了竞态条件。
c
#include <stdatomic.h>
atomic_int my_atomic_variable = ATOMIC_VAR_INIT(0);
ATOMIC_VAR_INIT
是一个宏,用于初始化原子变量。这个宏接受一个参数,将其用作原子变量的初始值。在上述例子中,ATOMIC_VAR_INIT(0)
的作用是将一个整数值 0
用作 atomic_int
类型的原子变量的初始值。
在 <stdatomic.h>
头文件中,原子类型的初始化通常可以使用 ATOMIC_VAR_INIT
宏,它的定义可能类似于以下方式:
c
#define ATOMIC_VAR_INIT(value) (value)
这个宏的本质是一个简单的宏,它接受一个值并将其返回。在 C 语言中,结构体和数组等类型的初始化通常需要使用特定的语法,但对于原子变量,可以使用 ATOMIC_VAR_INIT
来简化初始化过程。
在代码中,ATOMIC_VAR_INIT(0)
将 0
作为初始值传递给 atomic_int
类型的原子变量 atomic_variable
。这是一种方便的初始化原子变量的方式。
3. 原子变量与普通变量的区别
与普通变量相比,原子变量提供了更强的同步保证。在多线程环境中,普通变量的读写可能发生竞态条件,而原子变量通过原子操作函数确保了操作的原子性。这种保证使得在并发环境中更容易编写线程安全的代码。
c
int common_variable = 0; // 普通变量
atomic_int atomic_variable = ATOMIC_VAR_INIT(0); // 原子变量
原子变量的存储位置通常由编译器和程序的具体实现决定,而不是由原子变量本身决定。这些与普通变量一致。原子变量可以存储在堆区、栈区或全局区,具体取决于它们的声明位置和作用域。
-
全局区/静态存储区: 如果原子变量是在全局作用域声明,或者使用
static
关键字在函数内部声明,它们通常被分配在全局区或静态存储区。这意味着它们在程序的整个生命周期内存在。c#include <stdatomic.h> atomic_int global_atomic_variable = ATOMIC_VAR_INIT(0); int main() { // ... return 0; }
-
堆区: 如果原子变量是通过动态内存分配函数(如
malloc
、calloc
等)在堆上分配的,那么它们将存储在堆区。这通常涉及到使用指针管理原子变量。c#include <stdatomic.h> #include <stdlib.h> int main() { atomic_int *heap_atomic_variable = malloc(sizeof(atomic_int)); *heap_atomic_variable = ATOMIC_VAR_INIT(0); // ... free(heap_atomic_variable); // 记得释放内存 return 0; }
-
栈区: 如果原子变量是在函数内部声明且没有使用
static
关键字,它们通常被分配在栈上。这意味着它们的生命周期受限于函数的执行期间。c#include <stdatomic.h> void someFunction() { atomic_int local_atomic_variable = ATOMIC_VAR_INIT(0); // ... } int main() { someFunction(); return 0; }
总的来说,原子变量的存储位置与普通变量类似,取决于它们的声明方式和作用域。原子操作主要关注于对变量的原子性操作,而不是变量的存储位置。
4. 原子锁的实现
原子锁是一种同步机制,用于确保在同一时刻只有一个线程能够进入临界区。自旋锁是一种常见的原子锁,它使用忙等待的方式来获取锁。
c
#include <stdatomic.h>
typedef struct {
atomic_flag flag;
} spinlock_t;
void spinlock_lock(spinlock_t *lock) {
while (atomic_flag_test_and_set(&lock->flag)) {
// 在这里可以进行自旋等待或者调用 __mm_pause() 以提高性能
}
}
void spinlock_unlock(spinlock_t *lock) {
atomic_flag_clear(&lock->flag);
}
atomic_flag_test_and_set
是 C11 标准中定义的原子操作函数。它用于原子地测试并设置一个 atomic_flag
,通常用于实现自旋锁或其他类似的同步机制。
以下是 atomic_flag_test_and_set
的函数原型:
c
_Bool atomic_flag_test_and_set(volatile atomic_flag *flag);
flag
是一个指向atomic_flag
的指针,表示需要进行测试和设置的标志。- 返回值是一个
_Bool
类型,表示在进行操作前,flag
的当前值是否为true
。如果当前值为true
,则返回true
,表示标志已经被设置;如果当前值为false
,则返回false
,表示标志在进行测试和设置前是未设置的。
这个操作可以被视为一个原子的比较和设置操作。在多线程环境中,它确保在进行测试和设置的整个过程中不会被中断,从而防止竞态条件。
下面是一个简单的例子,演示了 atomic_flag_test_and_set
的使用:
c
#include <stdatomic.h>
atomic_flag my_flag = ATOMIC_FLAG_INIT;
void acquire_lock() {
while (atomic_flag_test_and_set(&my_flag)) {
// 如果标志已经被设置,继续自旋等待
}
}
void release_lock() {
atomic_flag_clear(&my_flag); // 清除标志,释放锁
}
在这个例子中,acquire_lock
函数尝试获取锁,它使用 atomic_flag_test_and_set
来原子地测试并设置 my_flag
。如果 my_flag
已经被设置,说明锁已经被其他线程占用,当前线程将继续自旋等待。release_lock
函数用于释放锁,它使用 atomic_flag_clear
来清除 my_flag
,表示锁已经释放。
5. 实例代码
以下是一个简单的自旋锁的 C 语言实现:
自旋锁是一种同步机制,它使用忙等待(自旋)的方式来获取锁。在使用自旋锁时,如果锁已被其他线程占用,当前线程将一直循环检测锁是否被释放,而不是立即进入休眠状态。只有当锁被释放时,当前线程才能成功获取锁。
c
#include <stdatomic.h>
typedef struct {
atomic_flag flag;
} spinlock_t;
void spinlock_init(spinlock_t *lock) {
atomic_flag_clear(&lock->flag);
}
void spinlock_lock(spinlock_t *lock) {
while (atomic_flag_test_and_set(&lock->flag)) {
// 如果锁已经被占用,继续自旋等待
}
}
void spinlock_unlock(spinlock_t *lock) {
atomic_flag_clear(&lock->flag);
}
上述代码中,spinlock_t
结构体包含一个原子标志(atomic_flag
),用于表示锁的状态。spinlock_init
用于初始化自旋锁,spinlock_lock
用于上锁,spinlock_unlock
用于解锁。
使用这个自旋锁:
c
#include <stdio.h>
#include <pthread.h>
spinlock_t my_lock;
void *worker_thread(void *arg) {
int thread_id = *(int *)arg;
spinlock_lock(&my_lock);
// 临界区代码
printf("Thread %d is in critical section.\n", thread_id);
spinlock_unlock(&my_lock);
return NULL;
}
int main() {
spinlock_init(&my_lock);
pthread_t thread1, thread2;
int id1 = 1, id2 = 2;
pthread_create(&thread1, NULL, worker_thread, &id1);
pthread_create(&thread2, NULL, worker_thread, &id2);
pthread_join(thread1, NULL);
pthread_join(thread2, NULL);
return 0;
}
这个例子创建了两个线程,它们共享一个自旋锁。每个线程在进入临界区之前先调用 spinlock_lock
上锁,然后在退出临界区时调用 spinlock_unlock
解锁。这样,自旋锁确保在同一时刻只有一个线程能够进入临界区。