在多线程编程中,为了保证对共享资源的访问不会发生竞争条件(race condition),通常需要引入同步机制。本篇将结合一个实际的例子,讲解互斥锁(Mutex)、自旋锁(Spinlock)和原子操作(Atomic Operation)的原理与使用场景。
示例代码
下面是一段简单的多线程程序,多个线程对一个共享变量 count
进行累加操作。为了演示不同的同步方式,这段代码预留了四种写法,通过修改 #if 0
的宏开关,可以切换不同的同步方法。
#include <pthread.h>
#include <stdio.h>
#include <unistd.h>
#define THREAD_COUNT 10
pthread_mutex_t mutex;
pthread_spinlock_t spinlock;
// 使用x86汇编实现的原子加法
int inc(int *value, int add) {
int old;
__asm__ volatile(
"lock; xaddl %2,%1;"
: "=a" (old)
: "m" (*value), "a" (add)
: "cc", "memory"
);
return old;
}
void *thread_callback(void *arg) {
int *pcount = (int *)arg;
int i = 0;
while (i++ < 100000) {
#if 0
(*pcount)++; // 普通加法(无保护)
#elif 0
pthread_mutex_lock(&mutex);
(*pcount)++;
pthread_mutex_unlock(&mutex);
#elif 0
inc(pcount, 1); // 原子加法
pthread_spin_lock(&spinlock);
(*pcount)++;
pthread_spin_unlock(&spinlock);
#else
inc(pcount, 1); // 纯原子加法
#endif
usleep(1); // 稍作休眠,模拟真实环境
}
return NULL;
}
int main() {
pthread_t threadid[THREAD_COUNT] = {0};
pthread_mutex_init(&mutex, NULL);
pthread_spin_init(&spinlock, PTHREAD_PROCESS_SHARED);
int i = 0;
int count = 0;
for (i = 0; i < THREAD_COUNT; i++) {
pthread_create(&threadid[i], NULL, thread_callback, &count);
}
for (i = 0; i < 100; i++) {
printf("count : %d\n", count);
sleep(1);
}
return 0;
}
1. 互斥锁(Mutex)
互斥锁用于保护共享资源不被多个线程同时访问,从而避免数据竞争和不一致性问题。
优点
-
适合临界区较长的场景,避免CPU空转浪费资源。
-
简单易用。
缺点
-
有上下文切换开销:当线程挂起和唤醒时,操作系统需要进行线程调度,代价比较大。
代码示例:
pthread_mutex_lock(&mutex);
(*pcount)++;
pthread_mutex_unlock(&mutex);
2. 自旋锁(Spinlock)
原理
当一个线程尝试获取已被占用的自旋锁时,它会一直循环检查锁是否可用(即“自旋”),而不会让出CPU。
优点
-
适合临界区非常短的场景(加锁时间短、线程很快就释放锁)。
-
避免了线程挂起、唤醒带来的系统调用开销。
缺点
-
会消耗大量CPU,如果锁等待时间长,会导致性能下降。
-
不适合高并发且锁粒度粗大的场景。
代码示例:
pthread_spin_lock(&spinlock);
(*pcount)++;
pthread_spin_unlock(&spinlock);
3. 原子操作(Atomic Operation)
原理
原子操作是通过硬件指令(比如x86的lock
前缀指令)保证某个操作不可中断,一旦开始执行就一直执行完毕。
__asm__ volatile(
"lock; xaddl %2,%1;"
: "=a" (old)
: "m" (*value), "a" (add)
: "cc", "memory"
);
-
lock
保证了总线锁定,防止其他CPU访问同一内存地址。 -
xaddl
是交换并加指令。
优点
-
无需显式加锁,速度极快。
-
避免了锁竞争和上下文切换。
缺点
-
仅适合简单的读-改-写操作,复杂逻辑还是需要锁保护。
-
编写和理解原子指令较为复杂。
4. 不同方式的对比总结
方式 | 适用场景 | 优点 | 缺点 |
---|---|---|---|
普通操作 | 单线程环境 | 简单,快速 | 多线程下出现竞争条件 |
互斥锁 | 多线程+临界区长 | 简单,保护性强 | 可能引发线程挂起,调度开销大 |
自旋锁 | 多线程+临界区短 | 快速避免阻塞 | 高CPU占用,易造成性能问题 |
原子操作 | 极短操作(如计数器) | 极高性能 | 功能有限,复杂逻辑不适用 |
额外知识补充:关于 #if 0
这些宏指令的小技巧
#if 0
(*pcount)++;
#elif 0
pthread_mutex_lock(&mutex);
(*pcount)++;
pthread_mutex_unlock(&mutex);
#elif 0
inc(pcount,1);
pthread_spin_lock(&spinlock);
(*pcount)++;
pthread_spin_unlock(&spinlock);
#else
inc(pcount,1);
#endif
这一块使用了预处理指令,特别是 #if
、#elif
、#else
和 #endif
。
1. #if
/ #elif
/ #else
/ #endif
是什么?
它们是 条件编译指令,在编译阶段(还没真正编译成机器代码时),由编译器的预处理器决定哪些代码需要被保留、哪些代码需要被忽略。
-
#if 0
表示这块代码不会被编译进去(因为 0 是假,条件不成立)。 -
#if 1
表示这块代码会被编译。 -
#elif
是else if的缩写,可以继续判断其他条件。 -
#else
表示以上都不满足时,执行这一块。 -
#endif
表示条件编译结束。
简单来说:这就像一个只在编译阶段起作用的 if-else 判断!
2. 为什么要这样写?
主要是为了方便切换测试不同的代码块。
比如在多线程同步示例中,我可以很快地切换不同的写法,只要把对应的 0
改成 1
:
-
想测试普通加法:把第一个
#if 0
改成#if 1
-
想测试加锁版本:把第二个
#elif 0
改成#elif 1
-
想测试原子操作:把第三个
#elif 0
改成#elif 1