多线程怎同步?

访客 性能优化 2

本文目录导读:

  1. 常见的同步机制
  2. 同步实践原则(避免常见陷阱)
  3. 语言/平台特定推荐

多线程同步的核心是协调多个线程对共享资源的访问,以防止数据竞争、死锁等问题,不同语言和场景有不同的实现方式,下面从常见同步机制实践原则两个维度来讲解。

常见的同步机制

这些机制是为了解决“多个线程同时读写同一份数据”的问题。

互斥锁

最基础的同步工具,保证同一时刻只有一个线程能访问共享资源。

  • 原理:线程在访问资源前加锁,访问后解锁,其他线程尝试加锁时会阻塞,直到锁被释放。

  • 适用场景:任何需要互斥访问的临界区(如修改全局变量、写入文件)。

  • 示例(C++)

    #include <mutex>
    std::mutex mtx;
    int shared_data = 0;
    void increment() {
        mtx.lock();
        shared_data++;  // 临界区,只有一个线程能执行
        mtx.unlock();
    }
  • 注意事项

    • 避免忘记释放锁:使用 std::lock_guardsynchronized(如 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); // 原子加法
    }
  • 限制:只能操作单个内存单元,不能保护复杂的临界区(多变量一致性)。

机制 核心能力 适用于 缺点
互斥锁 完全互斥 所有临界区 性能开销大、可能死锁
读写锁 读共享、写独占 读多写少 写线程可能饥饿
条件变量 线程间通知 等待-通知模式 复杂、容易漏唤醒
信号量 控制并发数 资源池、限流 语义抽象、易用错
原子操作 单变量无锁 简单计数、状态 不能解决复杂同步

同步实践原则(避免常见陷阱)

  1. 尽量使用高级抽象:优先选择语言内置的同步工具(如 Java synchronized、Go channel、Python Queue),避免手搓底层锁。

    • 错误示例:手动用 while(flag.load()) {} 自旋等待,浪费CPU且容易死锁。
    • 正确示例:用 std::condition_variableSync.WaitGroup(Go)。
  2. 避免死锁

    • 加锁顺序:所有线程按相同顺序获取多个锁。
    • 超时回退:使用 try_lock 或带超时的锁(如 std::timed_mutex)。
    • 锁层次:定义锁的层级,低层锁持有时不允许再获取高层锁。
  3. 最小化临界区:只在必要时加锁,不要在锁内执行耗时的I/O操作或外部调用。

    • 坏例子lock(); send_http_request(); data = parse(); unlock();
    • 好例子data = fetch_data(); lock(); shared = data; unlock();
  4. 注意编译器和CPU重排:在多线程中,编译器或CPU可能重排指令导致意外结果,使用内存序(如 acquire-release 语义)或内存屏障

    • 大多数语言的高级同步工具(如 std::mutex)内部已经包含了正确的内存序。
  5. 考虑无锁编程(Lock-Free):对于极高性能要求(游戏引擎、高频交易),可以用 CAS(比较并交换)设计无锁数据结构,但极其复杂,非必要不轻易使用

语言/平台特定推荐

  • C++std::mutexstd::lock_guardstd::condition_variablestd::atomicstd::shared_mutex,C++17/20 还引入了 std::scoped_lock(可同时锁多个互斥体)、信号量等。
  • Javasynchronized 块(内置锁)、ReentrantLock(显示锁)、ReadWriteLockSemaphoreCountDownLatchAtomicInteger
  • Pythonthreading.Lockthreading.RLock(可重入锁)、threading.Conditionthreading.Semaphorequeue.Queue(线程安全队列,生产者-消费者首选)。
  • Go推崇通过通道(channel)通信来同步,而不是用共享内存加锁。sync.Mutexsync.RWMutexsync.WaitGroup 也常用。
  • 需求是简单的互斥访问 → 互斥锁。
  • 读多写少 → 读写锁。
  • 需要线程等待特定条件 → 条件变量。
  • 控制并发数量 → 信号量。
  • 只需原子地修改一个数字或指针 → 原子操作。

最后记住:同步是必要的,但也是昂贵的,尽量减少线程间共享状态,采用不可变数据线程本地存储(Thread Local Storage),能从根源上降低对同步的依赖。

标签: 锁机制

抱歉,评论功能暂时关闭!