一、为什么需要同步:竞争条件的本质
如果说创建线程像把饭煮熟,那卖饭时三个线程同时读取余额(都读到 10)并竞相扣减1,最后余额反复拐,一个一个又消耗,该怎么搞?这场景的根本问题在于操作不是原子的。
实际上,CPU 缓存一致性协议(如 MESI)、编译器重排序的存在,导致多线程下对共享数据的访问远不像直觉预期。两个线程同时自增一个计数器,最终结果可能小于预期。这种竞争条件(Race Condition)正是同步原语要解决的根本问题。
同步与锁的不同用途:
- Read-Read:可并发,无冲突
- Read-Write:读写之间必须同步
- Write-Write:不写保护即产生数据损坏
典型的应用场景:LVS + nginx 负载均衡场景下,多个 worker 进程同时读取用户账号索引列表没有问题;但当用户修改账号索引,又有其他线程同时操作服务登录数据,就必须通过锁保护。
二、pthread锁体系:互斥锁、读写锁与自旋锁
2.1 互斥锁(Mutex)
pthread_mutex_t 是最常用的同步原语,通过 futex 系统调用实现。
// 初始化与使用
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
void add_user(user_list_t* list, user_t* user) {
pthread_mutex_lock(&lock); // 锁定,若已锁则阻塞
TAILQ_INSERT_TAIL(&list->users, user, entries);
pthread_mutex_unlock(&lock); // 解锁
}
// 非阻塞尝试
if (pthread_mutex_trylock(&lock) == 0) {
// 获得锁,处理任务
pthread_mutex_unlock(&lock);
}
适用场景:修改共享的关联数据结构(哈希表、链表),保证操作的原子性。
2.2 读写锁(RWLock)
读写锁让多个读者同时访问,写者却需独占。
pthread_rwlock_t user_lock = PTHREAD_RWLOCK_INITIALIZER;
// 读者:查询用户,多线程可并发
void query_user(shared_data_t* ctx) {
pthread_rwlock_rdlock(&user_lock);
// 安全读取共享数据
pthread_rwlock_unlock(&user_lock);
}
// 写者:新增/删除,需独占访问
void modify_user(uint32_t uid) {
pthread_rwlock_wrlock(&user_lock);
// 独占修改
pthread_rwlock_unlock(&user_lock);
}
适用场景:读多写少的共享状态,如配置项、索引表。
⚠️ 读写锁有两大陷阱:1️⃣ "写者饥饿"—一旦写者等待,新来的读者又会获得锁,写者可能无限等待;2️⃣ 写者优先级高于读者时,若持续有读者则写者也无法执行。
2.3 自旋锁(Spinlock)
自旋锁不发生用户态到内核态切换,而是在用户态连续检查锁状态。这使其必须为短临界区而设计。
pthread_spinlock_t sl;
pthread_spin_init(&sl, PTHREAD_PROCESS_PRIVATE);
void fast_counter_inc(fast_counter_t* c) {
pthread_spin_lock(&c->sl); // 自旋等待
c->value++; // 极短临界区
pthread_spin_unlock(&c->sl);
}
性能对比:由于无需系统调用,自旋锁的加解锁耗时在十几纳秒级,互斥锁则 2~50µs(含内核调度开销)。但自旋锁浪费 CPU 周期,故不适用于持续时间长的操作。
三、内存屏障(Memory Barrier)
现代处理器与缓存一致性协议(如 MESI)会导致对存储器的访问顺序被重排。内存屏障强制指令在该点前后出现特定的内存可见性。
3.1 六种内存序(memory_order)
| 内存序 | 保证 | 常见场景 |
|---|---|---|
memory_order_relaxed | 仅原子性,无顺序保证 | 独立计数器(如统计) |
memory_order_acquire | 该点之后的读写不能重排到之前 | 锁的 lock() |
memory_order_release | 该点之前的读写必须完成才可见 | 锁的 unlock() |
memory_order_acq_rel | 同时包含 acquire 和 release | fetch_add, compare_exchange |
memory_order_seq_cst | 全局顺序一致性(默认,最严格) | 全局标志位 |
💡 经验法则:写入用 release,读取用 acquire,RMW(读-改-写)用 acq_rel。只有独立计数器才用 relaxed。
3.2 经典应用:Seqlock(顺序锁)
Seqlock 是 Linux 内核 include/linux/seqlock.h 的经典设计,它允许写者不阻塞读者,读者通过重试检测写冲突。
// 读者侧
unsigned int seq_num;
do {
seq_num = read_seqbegin(&seq_lock); // 读取序列号
// 读取数据(不可阻塞写者)
...
} while (read_seqretry(&seq_lock, seq_num)); // 序列号变化则重试
// 写者侧
write_seqlock(&seq_lock); // 获取写锁,序列号变为奇数
// 写数据...
write_sequnlock(&seq_lock); // 序列号变为偶数
适用场景:小数据(通常 < 64 字节)、读多写少,且写操作很短。/proc 文件系统广泛使用。
四、无锁编程(Lock-Free Programming)
4.1 C11 <stdatomic.h> 全景
#include <stdatomic.h>
// 原子变量
atomic_int counter = ATOMIC_VAR_INIT(0);
// 基本原子操作
atomic_store(&counter, 10);
int prev = atomic_fetch_add(&counter, 1); // 原子自增,返回旧值
int value = atomic_load(&counter); // 原子读取
// 核心原语:CAS (Compare-And-Swap)
// 如果 *obj == *expected,则写入 desired 并返回 true
bool ok = atomic_compare_exchange_strong(&counter, &expected, desired);
// weak 版本:可能伪失败(适合自旋循环)
bool ok = atomic_compare_exchange_weak(&counter, &expected, desired);
注意:atomic_compare_exchange_strong 保证只要值相等就成功;weak 版本可能在值相等时也失败(CPU 架构原因),适合放在循环中使用。
4.2 经典无锁数据结构:SPSC Ring Buffer
#include <stdatomic.h>
#include <stdint.h>
#define QUEUE_SIZE 65536
#define QUEUE_MASK (QUEUE_SIZE - 1)
typedef struct {
unsigned char buffer[QUEUE_SIZE];
atomic_uint head; // 生产者递增(仅生产者写,仅消费者读)
atomic_uint tail; // 消费者递增(仅消费者写,仅生产者读)
} ring_buffer_t;
// 生产者入队
bool ring_push(ring_buffer_t* rb, unsigned char byte) {
unsigned int h = atomic_load_explicit(&rb->head, memory_order_relaxed);
unsigned int next = (h + 1) & QUEUE_MASK;
if (next == atomic_load_explicit(&rb->tail, memory_order_acquire))
return false; // 队列满
rb->buffer[h] = byte;
atomic_store_explicit(&rb->head, next, memory_order_release);
return true;
}
// 消费者出队
bool ring_pop(ring_buffer_t* rb, unsigned char* byte) {
unsigned int t = atomic_load_explicit(&rb->tail, memory_order_relaxed);
if (t == atomic_load_explicit(&rb->head, memory_order_acquire))
return false; // 队列空
*byte = rb->buffer[t];
atomic_store_explicit(&rb->tail, (t + 1) & QUEUE_MASK, memory_order_release);
return true;
}
上述 Single-Producer Single-Consumer 实现无需任何互斥锁,仅需正确配对的 acquire/release 语义:生产者在 release store head 之前保证数据写入可见,消费者在 acquire load head 之后保证读到完整数据。
五、ABA问题与解决方案
5.1 什么是ABA问题?
情景:线程 T1 读取值 A,准备 CAS 替换为 C。但在此间隙,线程 T2 将值改为 B 又改回 A。T1 的 CAS 仍然成功,因为值"还是 A",但期间发生了 T1 不知道的状态变化,可能导致数据结构不一致。
5.2 解决方案:Tagged Pointer(标签指针)
#include <stdatomic.h>
#include <stdint.h>
// 64位系统:利用指针高位存储标签(x86-64 只用 48 位地址空间)
typedef union {
struct {
void* ptr;
uint16_t tag;
};
unsigned __int128 raw; // 16字节,用 CMPXCHG16B 原子操作
} tagged_ptr_t;
// CAS 操作同时比较指针和标签
// 每次修改指针时 tag++,即使指针地址相同,tag 不同也会 CAS 失败
⚠️ 代价:需要16字节原子操作(CMPXCHG16B),在部分32位系统上可能退化为全局锁。ABA问题在无锁编程中非常常见,认识与解决是每个并发开发者的必经之路。
六、Hazard Pointer:无锁内存回收
在无锁数据结构中,一个核心问题:当从链表中摘下一个节点后,如何安全释放其内存?其他线程可能仍在访问该节点,直接 free 会导致 use-after-free。
Hazard Pointer 由 M. Michael 于 2004 年提出,核心思想:
- 每个线程拥有本地
hazard_pointers[]数组,声明 "我正在访问这些对象" - 访问共享对象前,将其地址写入 hazard pointer(带 release 语义)
- 释放节点前,扫描所有线程的 hazard_pointers,确认无人引用该节点
// 核心逻辑伪代码
void retire_node(node_t* node) {
// 放入线程本地退休列表
local_retired_list.push(node);
if (local_retired_list.size() >= THRESHOLD) {
// 收集所有线程的 hazard pointers
set<void*> hp_set = scan_all_hazard_pointers();
// 释放不在 hp_set 中的节点
for (node : local_retired_list) {
if (!hp_set.contains(node)) free(node);
}
}
}
著名应用:Folly ConcurrentHashMap、DPDK rte_ring、内核 RCU。
七、Linux 内核同步原语一览
| 同步原语 | 用途 | 版本/场景 |
|---|---|---|
spinlock_t | SMP短临界区(不可睡眠) | 2.6+ 核心路径 |
rw_semaphore | 内核态读写锁(支持优先级继承) | 驱动广泛采用 |
mutex | 内核态互斥锁(可睡眠,替代早期 semaphore) | 替代二元信号量 |
rcu_read_lock() | RCU 读侧临界区(无锁、极轻) | 读多写极少 |
atomic_t | 通用原子操作(引用计数、标志位) | 通用 |
percpu | CPU本地变量(避免缓存行弹跳) | 高性能计数器 |
local_irq_save() | 关中断(配合自旋锁防中断抢占) | 驱动短临界区 |
7.1 RCU(Read-Copy-Update)实践
RCU 是 Linux 内核最成功的无锁设计之一:读侧完全无锁,写者先创建新副本再原子替换。
// 读侧(零开销)
rcu_read_lock();
p = rcu_dereference(head.next); // 原子读取指针
// 访问 p->data...
rcu_read_unlock();
// 写侧(发布新版本)
new_node = kmalloc(sizeof(*new_node), GFP_KERNEL);
new_node->data = new_value;
new_node->next = head.next;
rcu_assign_pointer(head.next, new_node); // 原子发布
synchronize_rcu(); // 等待所有读侧退出(Grace Period)
kfree(old_node); // 安全回收旧节点
💡 RCU 的读侧开销近似为零(仅关闭抢占),非常适合读多写极少的场景(路由表、模块引用计数、文件描述符表)。
八、生产陷阱与实战经验
8.1 伪共享(False Sharing)
两个逻辑独立的变量恰好在同一个缓存行(通常64字节),不同 CPU 核心分别高频写入它们,导致缓存行在 CPU 之间反复弹跳("MESI 协议风暴"),性能骤降。
// 错误示例
struct {
atomic_int counter_a; // CPU0 高频写入
atomic_int counter_b; // CPU1 高频写入
} shared; // A 和 B 在同一缓存行!
// 正确方案:缓存行对齐隔离
struct {
atomic_int counter_a __attribute__((aligned(64)));
char _pad[60];
atomic_int counter_b __attribute__((aligned(64)));
} isolated;
8.2 死锁预防
死锁:互相持有对方需要的锁且互相等待。
⚠️ 黄金法则:所有线程按相同顺序加锁;必要时使用 pthread_mutex_trylock + 回退策略检测并打破死锁。
8.3 优先级反转(Priority Inversion)
低优先级线程持有锁,中等优先级线程抢占 CPU,高优先级线程被低优先级间接阻塞。解决方案:优先级继承协议(PTHREAD_PRIO_INHERIT),低优先级线程临时继承等待者的高优先级。
8.4 同步机制性能基准
| 同步机制 | 无争用延迟 | 高争用扩展性 | 适用场景 |
|---|---|---|---|
| 互斥锁(mutex) | 25~50ns(含syscall) | 低(一核串行) | 临界区 ≥ 1µs |
| 自旋锁(spinlock) | 15~30ns(用户态) | 低(消耗CPU) | 极短临界区 |
| 无锁CAS | 8~20ns(单指令) | 中等(失败重试) | 简单计数/状态 |
| RCU | 读 ≈ 0 | 高(读侧无锁) | 读:写 > 100:1 |
| Seqlock | 读者 5~15ns | 中 | 小数据频繁读 |
| 分片(Sharding) | 近似 0 | 高(无通信) | 可分区计数器 |
九、生产案例:Nginx 共享内存计数器
Nginx 使用原子操作实现 worker 进程间的统计计数器共享:
// 共享内存中分配原子计数器
ngx_atomic_t* shared_counter = mmap_shared(sizeof(ngx_atomic_t));
// Worker A:接收请求并原子递增
ngx_atomic_fetch_add(shared_counter, 1);
// Worker B:定时器中读取并清0
ngx_int_t value = ngx_atomic_cmp_set(shared_counter, 0, 0);
// 将 value 写入日志
关键优势:无需跨进程信号量,开销最小化。代价:读取时刻不保证绝对精确(写者随时可能在旁边修改),适合毫秒级精度的监控统计。
十、总结
Linux 用户态并发编程本质是在并行度与编程复杂度之间权衡的工程艺术。锁模型建立在互斥临界区上以牺牲并行为代价换取编程简单;无锁模型以"基于 CAS 的协议"换取极致并行,但代价是复杂性急剧上升:需要处理 ABA、内存屏障、无锁回收等问题。
实战选择路标:
- 临界区大于 ~1µs → 首选互斥锁(
std::mutex) - 极短临界区 + 低争用 → 自旋锁或原子 CAS
- 读远多于写(100:1以上)→ RCU / Seqlock / Hazard Pointer
- 多写场景 → 考虑无锁 ring buffer 或分片计数
- 始终测量再优化 →
perf c2c检测伪共享,perf lock分析锁争用
一句话:合适的永远胜于无锁。无锁未必更快,锁并非都应消除——关键是识别真临界域、针对基准测量、谨慎选择机制。并发编程是一生的修行,每次正确选择都建立在对硬件、OS 和应用负载的深入理解之上。
1. POSIX Threads Programming, Lawrence Livermore National Lab
2.
stdatomic.h — C11 Memory Model (ISO/IEC 9899:2011 §7.17)3. Linux Kernel Documentation:
memory-barriers.txt4. Paul E. McKenny, "RCU Usage In the Linux Kernel", IBM 2013
5. M. Michael, "Hazard Pointers: Safe Memory Reclamation for Lock-Free Objects", IEEE TPDS 2004
6. Herb Sutter, "Lock-Free Programming or, How to Humans Humble Me", CppCon 2014

发表评论 取消回复