本文目录导读:
多线程同步的核心是协调多个线程对共享资源的访问,以防止数据竞争、死锁等问题,不同语言和场景有不同的实现方式,下面从常见同步机制和实践原则两个维度来讲解。
常见的同步机制
这些机制是为了解决“多个线程同时读写同一份数据”的问题。
互斥锁
最基础的同步工具,保证同一时刻只有一个线程能访问共享资源。
-
原理:线程在访问资源前加锁,访问后解锁,其他线程尝试加锁时会阻塞,直到锁被释放。
-
适用场景:任何需要互斥访问的临界区(如修改全局变量、写入文件)。
-
示例(C++):
#include <mutex> std::mutex mtx; int shared_data = 0; void increment() { mtx.lock(); shared_data++; // 临界区,只有一个线程能执行 mtx.unlock(); } -
注意事项:
- 避免忘记释放锁:使用
std::lock_guard或synchronized(如 Java 的synchronized块)等 RAII(资源获取即初始化)风格自动管理锁。 - 锁的粒度:粒度太粗(整个大函数加锁)会降低性能;太细(只锁一两行)可能逻辑复杂且易死锁。
- 避免忘记释放锁:使用
读写锁
优化了读多写少的场景,允许多个线程同时读,但写操作必须独占(读写互斥、写写互斥)。
-
原理:锁有两种模式——读模式和写模式,读模式可共享,写模式排他。
-
适用场景:缓存、配置表、查找表等读操作远多于写操作的数据结构。
-
示例(C++17):
#include <shared_mutex> std::shared_mutex rw_mtx; int cache_data; // 读线程 int read() { std::shared_lock<std::shared_mutex> lock(rw_mtx); // 共享锁 return cache_data; } // 写线程 void write(int val) { std::unique_lock<std::shared_mutex> lock(rw_mtx); // 独占锁 cache_data = val; }
条件变量
用于线程间的通知机制,一个线程等待某个条件满足,另一个线程在条件变化时唤醒等待线程。
-
原理:通常与互斥锁配合使用,线程先检查条件,若不满足则进入等待状态并释放锁;条件发生时,另一个线程通知等待线程重新获取锁并检查条件。
-
适用场景:生产者-消费者模式、任务队列(有空闲则消费,有新任务则生产)。
-
示例(C++):
std::mutex mtx; std::condition_variable cv; bool ready = false; // 等待线程 void waiter() { std::unique_lock<std::mutex> lock(mtx); cv.wait(lock, []{ return ready; }); // 等待 ready == true // ... 执行操作 } // 通知线程 void notifier() { { std::lock_guard<std::mutex> lock(mtx); ready = true; } cv.notify_one(); // 唤醒一个等待线程 }重要原则:始终使用
while循环(或带谓词的wait)来检查条件,防止虚假唤醒。
信号量
一个计数器,控制同时访问某个资源的线程数量。
-
原理:
P(获取)操作减少计数,若为0则阻塞;V(释放)操作增加计数,并唤醒一个等待线程。 -
适用场景:控制并发连接数、限流、有限资源池(如数据库连接池)。
-
示例(C++20):
#include <semaphore> std::counting_semaphore<5> sem(5); // 最多5个线程并发 void worker() { sem.acquire(); // 获取许可,若满则阻塞 // ... 处理任务 sem.release(); // 释放许可 } -
与互斥锁的区别:互斥锁是“凭票入场,只能一人”,信号量是“有X张票,可X人同时进”。
原子操作
无锁同步,利用CPU指令保证单个变量的读-改-写操作不可拆分。
-
原理:通过硬件(如CAS指令)实现,避免锁的开销。
-
适用场景:简单的计数器、标志位、共享状态统计。
-
示例(C++):
#include <atomic> std::atomic<int> counter(0); void inc() { counter.fetch_add(1, std::memory_order_relaxed); // 原子加法 } -
限制:只能操作单个内存单元,不能保护复杂的临界区(多变量一致性)。
| 机制 | 核心能力 | 适用于 | 缺点 |
|---|---|---|---|
| 互斥锁 | 完全互斥 | 所有临界区 | 性能开销大、可能死锁 |
| 读写锁 | 读共享、写独占 | 读多写少 | 写线程可能饥饿 |
| 条件变量 | 线程间通知 | 等待-通知模式 | 复杂、容易漏唤醒 |
| 信号量 | 控制并发数 | 资源池、限流 | 语义抽象、易用错 |
| 原子操作 | 单变量无锁 | 简单计数、状态 | 不能解决复杂同步 |
同步实践原则(避免常见陷阱)
-
尽量使用高级抽象:优先选择语言内置的同步工具(如 Java
synchronized、Gochannel、PythonQueue),避免手搓底层锁。- 错误示例:手动用
while(flag.load()) {}自旋等待,浪费CPU且容易死锁。 - 正确示例:用
std::condition_variable或Sync.WaitGroup(Go)。
- 错误示例:手动用
-
避免死锁:
- 加锁顺序:所有线程按相同顺序获取多个锁。
- 超时回退:使用
try_lock或带超时的锁(如std::timed_mutex)。 - 锁层次:定义锁的层级,低层锁持有时不允许再获取高层锁。
-
最小化临界区:只在必要时加锁,不要在锁内执行耗时的I/O操作或外部调用。
- 坏例子:
lock(); send_http_request(); data = parse(); unlock(); - 好例子:
data = fetch_data(); lock(); shared = data; unlock();
- 坏例子:
-
注意编译器和CPU重排:在多线程中,编译器或CPU可能重排指令导致意外结果,使用内存序(如
acquire-release语义)或内存屏障。- 大多数语言的高级同步工具(如
std::mutex)内部已经包含了正确的内存序。
- 大多数语言的高级同步工具(如
-
考虑无锁编程(Lock-Free):对于极高性能要求(游戏引擎、高频交易),可以用 CAS(比较并交换)设计无锁数据结构,但极其复杂,非必要不轻易使用。
语言/平台特定推荐
- C++:
std::mutex、std::lock_guard、std::condition_variable、std::atomic、std::shared_mutex,C++17/20 还引入了std::scoped_lock(可同时锁多个互斥体)、信号量等。 - Java:
synchronized块(内置锁)、ReentrantLock(显示锁)、ReadWriteLock、Semaphore、CountDownLatch、AtomicInteger。 - Python:
threading.Lock、threading.RLock(可重入锁)、threading.Condition、threading.Semaphore、queue.Queue(线程安全队列,生产者-消费者首选)。 - Go:推崇通过通道(channel)通信来同步,而不是用共享内存加锁。
sync.Mutex、sync.RWMutex、sync.WaitGroup也常用。
- 需求是简单的互斥访问 → 互斥锁。
- 读多写少 → 读写锁。
- 需要线程等待特定条件 → 条件变量。
- 控制并发数量 → 信号量。
- 只需原子地修改一个数字或指针 → 原子操作。
最后记住:同步是必要的,但也是昂贵的,尽量减少线程间共享状态,采用不可变数据或线程本地存储(Thread Local Storage),能从根源上降低对同步的依赖。
标签: 锁机制