第2章 线程同步精要

第2章 线程同步精要

线程同步的四项原则,按重要性排列:

  • 1.首要原则是尽量最低限度地共享对象,减少需要同步的场合。一个对象能不暴露给别的线程就不要暴露;如果要暴露,优先设置对象不可更改;实在不行才暴露可修改的对象,并用同步措施来充分保护它。
  • 2.其次是使用高级的并发编程构件,如TaskQueue、Producer- Consumer Queue、CountDownLatch等等。
  • 3.最后不得已必须使用底层同步原语(primitives)时,只用非递归的互斥器和条件变量,慎用读写锁,不要用信号量。
  • 4.除了使用atomic整数之外,不自己编写lock-free代码3,也不要用“内核级”同步原语4 5。不凭空猜测“哪种做法性能会更好”,比如spin lock vs. mutex。

2.1 互斥器(mutex)

单独使用mutex时,我们主要为了保护共享数据。我个人的原则是:

次要原则有:

  • 不使用跨进程的mutex,进程间通信只用TCP sockets。
  • 加锁、解锁在同一个线程,线程a不能去unlock线程b已经锁住的mutex(unique_lock自动保证)。
  • 必要的时候可以考虑用PTHREAD_MUTEX_ERRORCHECK来排错。【不懂】

2.1.1 只使用非递归的mutex

在同一个线程里多次对non-recursive mutex加锁会立刻导致死锁,我认为这是它的优点,能帮助我们思考代码对锁的期求,并且及早(在编码阶段)发现问题。
recursive mutex可能会隐藏代码里的一些问题。典型情况是你以为拿到一个锁就能修改对象了,没想到外层代码已经拿 到了锁,正在修改(或读取)同一个对象呢。如下:

#include<mutex>
#include<thread>
#include <vector>
#include <stdio.h>

class Foo
{
 public:
  void doit() const;
};

std::mutex mutex;
std::vector<Foo> foos;

void post(const Foo& f)
{
  std::lock_guard lock(mutex);
  foos.push_back(f);
}

void traverse()
{
  std::lock_guard lock(mutex);
  for (std::vector<Foo>::const_iterator it = foos.begin();
      it != foos.end(); ++it)
  {
    it->doit();
  }
}

void Foo::doit() const
{
  Foo f;
  post(f);
}

int main()
{
  Foo f;
  post(f);
  traverse();
}

说明:

  • 1.mutex是非递归的,于是死锁了。原因:traverse()获取到mutex,但是it->doit()中的post也需要获取到mutex,但是mutex是非递归的,所以同一线程不能多次获取此mutex。这就导致post()一直在等待traverse()释放锁,而traverse()一直在等待post()执行完成。
  • 2.mutex是递归的,由于push_back()可能(但不总是)导致vector迭代器失效,程序偶尔会crash。程序crash的愿意:当vector需要扩容时,它会创建一个新的存储空间,并将元素从旧的存储空间复制到新的存储空间。如果在复制期间进行迭代器操作,例如解引用、递增等,就会导致迭代器失效,因为指向旧存储空间的迭代器现在指向无效的内存位置。

这时候就能体现non-recursive的优越性:把程序的逻辑错误暴露出来。死锁比较容易debug,把各个线程的调用栈打出来8,只要每个函数不是特别长,很容易看出来是怎么死的。程序反正要死,不如死得有意义一点,留个“全尸”,让验尸(post-mortem)更容易些。如果确实需要在遍历的时候修改vector,有两种做法:

  • 一是把修改后,记住循环中试图添加或删除哪些元素,等循环结束了再依记录修改foos;
  • 二是用copy-on-write。(2.8中会介绍)

2.1.2 死锁

线程自己与自己死锁实例:

#include<mutex>

class Request
{
 public:
  void process() // __attribute__ ((noinline))
  {
    muduo::MutexLockGuard lock(mutex_);
    print();
  }

  void print() const // __attribute__ ((noinline))
  {
    muduo::MutexLockGuard lock(mutex_);
  }

 private:
  mutable muduo::MutexLock mutex_;
};

int main()
{
  Request req;
  req.process();
}

问题:process()中获取了锁mutex,但是process()中调用print(),需要在print()中再次获取锁,如果mutex是非递归锁,那么就会产生死锁。
解决方法:如果一个函数既可能在已加锁的情况下调用,又可能在未加锁的情况下调用,那么就拆成两个函数:一个函数是加锁的,一个函数的未加锁的,加锁的函数名为A,那么未加锁的函数名可以定义为AWithLockHold。

书中还介绍了一种死锁情况,如下:

main()中调用了对象A的printAll()(获取mutex_a),线程2调用了对象B的~Request()(获取mutex_b)。当printAll()需要调用对象B的print(),线程2占用着mutex_b,导致成printAll()无法继续执行。当~Request()需要调用对象A的remove(),线程1占用着mutex_a,导致成~Request()无法继续执行。所以调用两个带锁的对象的函数的时候需要小心。

2.2 条件变量(condition variable)

条件变量的学名叫管程(monitor)

条件变量只有一种正确使用的方式,几乎不可能用错。可以参考我写的另一篇文章:C++ 条件变量(condition_variable)、notify、wait、互斥量(mutex)、同步过程、BlockingQueue和CountDownLatch
对于wait端:

  • 1.必须与mutex一起使用,该布尔表达式的读写需受此mutex保护。
  • 2.在mutex已上锁的时候才能调用wait()。
  • 3.把判断布尔条件和wait()放到while循环中

对于signal/broadcast端:

  • 1.不一定要在mutex已上锁的情况下调用signal(理论上)。
  • 2.在signal之前一般要修改布尔表达式。修改布尔表达式通常要用mutex保护(至少用作full memory barrier)。
  • 3.注意区分signal与broadcast:“broadcast通常用于表明状态变化,signal通常用于表示资源可用。

2.3 不要用读写锁和信号量

2.3.1 不要用读写锁

读写锁:

  • 当有线程持有写锁时,其他线程不能获取读锁和写锁。如获取读锁时会判断是否有线程已经获取写锁,如果有,说明有线程在执行写,此时就会阻塞,直到写锁释放。
  • 当有线程持有读锁时,其他线程可以同时获取读锁,但不能获取写锁。
    【加读锁的原因】因为写的操作可能经过一系列的过程才能最终得到结果,如果不加读锁,那么可能读到写操作过程中的一个中间值。(参考:链接

在C++中,读写锁可以使用标准库提供的 std::shared_mutex 类型来表示。std::shared_mutex 类型在C++14中引入,可以用于实现读写锁。下面是使用读写锁的实例:

#include <iostream>
#include <thread>
#include <shared_mutex>
#include<vector>

std::shared_mutex mutex;
int data = 0;

void reader(int id) {
    while (true) {
        // 读取数据,使用 shared_lock
        std::shared_lock<std::shared_mutex> lock(mutex);  // 读锁
        std::cout << "Reader " << id << " read data: " << data << std::endl;
    }
}

void writer(int id) {
    while (true) {
        // 写入数据,使用 unique_lock
        std::unique_lock<std::shared_mutex> lock(mutex);  // 写锁
        data++;
        std::cout << "Writer " << id << " wrote data: " << data << std::endl;
        std::this_thread::sleep_for(std::chrono::seconds(1));
    }
}

int main() {
    // 创建 5 个读者线程和 2 个写者线程
    std::vector<std::thread> readers(5);
    std::vector<std::thread> writers(2);

    for (int i = 0; i < readers.size(); i++) {
        readers[i] = std::thread(reader, i);
    }

    for (int i = 0; i < writers.size(); i++) {
        writers[i] = std::thread(writer, i);
    }

    // 等待所有线程结束
    for (auto& reader : readers) {
        reader.join();
    }

    for (auto& writer : writers) {
        writer.join();
    }

    return 0;
}

代码说明:
在这个示例代码中,我们创建了 5 个读者线程和 2 个写者线程。每个读者线程会不停地读取 data 的值,并使用 std::shared_lock 进行加锁;每个写者线程会不停地将 data 的值加 1,并使用 std::unique_lock 进行加锁。
使用 std::shared_lock 可以允许多个线程同时对共享数据进行读取操作,而使用 std::unique_lock 可以保证在写入数据时只有一个线程可以访问共享数据。这样就可以避免数据竞争和死锁的问题。

初学者常干的一件事情是,一见到某个共享数据结构频繁读而很少写,就把mutex替换为rwlock。甚至首选rwlock来保护共享状态,这不见得是正确的:

  • 从正确性方面来说,一种典型的易犯错误是在持有read lock的时 候修改了共享数据。这通常发生在程序的维护阶段,为了新增功能, 程序员不小心在原来read lock保护的函数中调用了会修改状态的函数。 这种错误的后果跟无保护并发读写共享数据是一样的。

  • 从性能方面来说,读写锁不见得比普通mutex更高效。无论如何 reader lock加锁的开销不会比mutex lock小,因为它要更新当前reader的数目。如果临界区很小,读锁竞争不激烈,那么mutex往往会更快。

  • reader lock可能允许提升(upgrade)为writer lock,也可能不允许 提升。考虑§2.1.1的post()和traverse()示例,如果用读写锁来保护foos 对象,那么post()应该持有写锁,而traverse()应该持有读锁。如果允许 把读锁提升为写锁,后果跟使用recursive mutex一样,会造成迭代器失 效,程序崩溃。如果不允许提升,后果跟使用non-recursive mutex一 样,会造成死锁。我宁愿程序死锁,留个“全尸”好查验。

  • 通常reader lock是可重入的,writer lock是不可重入的。但是为了 防止writer饥饿,writer lock通常会阻塞后来的reader lock,因此reader lock在重入的时候可能死锁。另外,在追求低延迟读取的场合也不适用读写锁。

从正确性方面来说,一种典型的易犯错误是在持有read lock的时 候修改了共享数据。这通常发生在程序的维护阶段,为了新增功能, 程序员不小心在原来read lock保护的函数中调用了会修改状态的函数。 这种错误的后果跟无保护并发读写共享数据是一样的。举例说明如下:

#include <iostream>
#include <thread>
#include <mutex>
#include <shared_mutex>

std::shared_mutex mtx;
int shared_data = 0;

void read_data() {
  std::shared_lock<std::shared_mutex> lock(mtx);
  std::cout << "Read shared data: " << shared_data << std::endl;
}

void modify_data() {
  std::unique_lock<std::shared_mutex> lock(mtx);
  ++shared_data;
}

void error_function() {
  std::shared_lock<std::shared_mutex> lock(mtx);
  modify_data();  // 会导致错误的修改共享数据的函数
}

int main() {
  // 读取共享数据
  std::thread t1(read_data);
  std::thread t2(read_data);
  t1.join();
  t2.join();

  // 修改共享数据
  std::thread t3(modify_data);
  t3.join();

  // 错误的调用导致数据破坏
  std::thread t4(error_function);
  t4.join();

  return 0;
}

muduo线程库有意不提供读写锁的封装,因为我还没有在工作中遇到过用rwlock替换普通mutex会显著提高性能的例子。相反,我们一般建议首选mutex。
遇到并发读写,如果条件合适,我通常会用§2.8的办法,而不用读 写锁,同时避免reader被writer阻塞。如果确实对并发读写有极高的性 能要求,可以考虑read-copy-update。

2.3.2 不要用信号量

信号量(Semaphore):我没有遇到过需要使用信号量的情况,无 从谈及个人经验。我认为信号量不是必备的同步原语,因为条件变量 配合互斥器可以完全替代其功能,而且更不易用错。除了[RWC]指出的 “semaphore has no notion of ownership”之外,信号量的另一个问题在于 它有自己的计数值,而通常我们自己的数据结构也有长度值,这就造 成了同样的信息存了两份,需要时刻保持一致,这增加了程序员的负 担和出错的可能。如果要控制并发度,可以考虑用muduo::ThreadPool。

说一句不知天高地厚的话,如果程序里需要解决如“哲学家就餐” 之类的复杂IPC问题,我认为应该首先检讨这个设计:为什么线程之间 会有如此复杂的资源争抢(一个线程要同时抢到两个资源,一个资源 可以被两个线程争夺)?如果在工作中遇到,我会把“想吃饭”这个事 情专门交给一个为各位哲学家分派餐具的线程来做,然后每个哲学家 等在一个简单的condition variable上,到时间了有人通知他去吃饭。从 哲学上说,教科书上的解决方案是平权,每个哲学家有自己的线程, 自己去拿筷子;我宁愿用集权的方式,用一个线程专门管餐具的分 配,让其他哲学家线程拿个号等在食堂门口好了。这样不损失多少效 率,却让程序简单很多。

2.5 线程安全的Singleton实现

使用 C++11 std::call_once 实现单例(C++11线程安全)

#include <iostream>
#include <memory>
#include <mutex>

class Singleton {
public:
    static std::shared_ptr<Singleton> getSingleton();

    void print() {
        std::cout << "Hello World." << std::endl;
    }

    ~Singleton() {
        std::cout << __PRETTY_FUNCTION__ << std::endl;
    }

private:
    Singleton() {
        std::cout << __PRETTY_FUNCTION__ << std::endl;
    }
};

static std::shared_ptr<Singleton> singleton = nullptr;
static std::once_flag singletonFlag;

std::shared_ptr<Singleton> Singleton::getSingleton() {
    std::call_once(singletonFlag, [&] {
        singleton = std::shared_ptr<Singleton>(new Singleton());
    });
    return singleton;
}

call_once:多线程环境中,线程的任务函数中调用函数A,但只希望A被调用一次。参考链接

2.6 sleep(3)不是同步原语

在程序的正常执行中,如果需要等待一段已知的时间,应该往 event loop里注册一个timer,然后在timer的回调函数里接着干活,因为 线程是个珍贵的共享资源,不能轻易浪费(阻塞也是浪费)。如果等 待某个事件发生,那么应该采用条件变量或IO事件回调,不能用sleep 来轮询。

如果多线程的安全性和效率要靠代码主动调用sleep来保证,这显然是设计出了问题。等待某个事件发生,正确的做法是用select()等价物或Condition,抑或(更理想地)高层同步工具;在用户态做轮询(polling)是低效的

【问题】“应该往 event loop里注册一个timer,然后在timer的回调函数里接着干活”是什么意思?

2.7 归纳与总结

前面几节内容归纳如下:

  • 线程同步的四项原则,尽量用高层同步设施(线程池、队列、倒计时);
  • 使用普通互斥器和条件变量完成剩余的同步任务,采用RAII和Scoped Locking(使用unique_lock/lock_guard等管理mutex)。

2.8 借shared_ptr实现copy-on-write

2.8.1 解决同一个线程里多次对non-recursive mutex加锁会立刻导致死锁

2.1.1中的代码展示了在同一个线程里多次对non-recursive mutex加锁会立刻导致死锁,2.1.1中的代码的问题是traverse()获取了锁,但是traverse()中调用的post想要再次获取锁,从而导致了死锁。本小节使用非递归的mutex并且保证不出现2.1.1中的代码不出现死锁,代码修改如下:

#include <stdio.h>

#include <cassert>
#include <iostream>
#include <mutex>
#include <thread>
#include <vector>

class Foo {
 public:
  void doit() const;
};

typedef std::vector<Foo> FooList;
typedef std::shared_ptr<FooList> FooListPtr;
std::mutex mutex;
FooListPtr g_foos = std::make_shared<FooList>();

void post(const Foo& f) {
  std::lock_guard<std::mutex> lock(mutex);
  if (!g_foos.unique()) {
    // reset():reset()包含两个操作。当智能指针中有值的时候,调用reset()会使引用计数减1.当调用reset(new xxx())
    // 重新赋值时,智能指针首先是生成新对象,然后将就对象的引用计数减1(当然,如果发现引用计数为0时,则析构旧对象),
    // 然后将新对象的指针交给智能指针保管。
    g_foos.reset(new FooList(*g_foos));
    printf("copy the whole list\n");
  }
  assert(g_foos.unique());
  g_foos->push_back(f);
}

void traverse() {
  FooListPtr foos;
  {
    std::lock_guard<std::mutex> lock(mutex);
    foos = g_foos;
    assert(!g_foos.unique());
  }
  for (std::vector<Foo>::const_iterator it = foos->begin(); it != foos->end();++it) {
    it->doit();
  }
}

void Foo::doit() const {
  Foo f;
  post(f);
}

int main() {
  Foo f;
  post(f);
  traverse();
}

上述代码中:

  • traverse()用一个栈上局部变量foos,它使得g_foos的引用计数增加(代表有线程正在调用traverse()),然后使用将锁释放,从而防止下面调用post()时产生死锁。
  • post():如果g_foos.unique()为true,我们可以放心地在原地(in-place)修改FooList。如果g_foos.unique()为false,说明这时别的线程正在读取FooList,我们不能原地修改,而是复制一份(L23),在副本上修改(L27)。由于是在副本上进行修改的,所以不会影响traverse()中对原始g_foos的遍历。由于traverse()早就释放了锁,所以这样就避免了死锁。

上述代码常见的几种错误:

// 错误一:直接修改g_foos 所指的FooList
void post(const Foo& f) {
  std::lock_guard<std::mutex> lock(mutex);
  g_foos->push_back(f);
}

错误原因:这会导致traverse()中遍历的foos也发生改变,当foos增加到一定程度,需要扩容时,它会创建一个新的存储空间,并将元素从旧的存储空间复制到新的存储空间。如果在复制期间进行迭代器操作,例如解引用、递增等,就会导致迭代器失效,因为指向旧存储空间的迭代器现在指向无效的内存位置。

//错误二:试图缩小临界区,把copying移出临界区
void post(const Foo& f) {
  FooListPtr newFoos(new FooList(*g_foos));
  newFoos->push_back(f);
  std::lock_guard<std::mutex> lock(mutex);
  g_foos = newFoos;  // 或者g_foos.swap(newFoos);
}

错误原因:线程1执行完new FooList(*g_foos)生成新对象,可能还未将新对象的指针交给g_foos保管,此时线程2对g_foos进行了修改,这时候线程1已经生成的对象就过期了。

//错误三:把临界区拆成两个小的,把copying 放到临界区之外
void post(const Foo& f) {
  FooListPtr oldFoos;
  {
    std::lock_guard<std::mutex> lock(mutex);
    oldFoos = g_foos;
  }
  FooListPtr newFoos(new FooList(*oldFoos)) ;
  newFoos->push_back(f);
  std::lock_guard<std::mutex> lock(mutex);
  g_foos = newFoos;   // 或者g_foos.swap(newFoos);
}

错误原因:和错误二的原因一样。可能在还未执行FooListPtr newFoos(new FooList(*oldFoos))的时候,g_foos就被其他线程修改了。

2.8.2 2.1.2中的死锁问题

2.1.2中的死锁问题:main()中调用了对象A的printAll()(获取mutex_a),线程2调用了对象B的Request()(获取mutex_b)。当printAll()需要调用对象B的print(),线程2占用着mutex_b,导致成printAll()无法继续执行。当Request()需要调用对象A的remove(),线程1占用着mutex_a,导致成~Request()无法继续执行。解决方法:采用本节前面post()和traverse()的方案【以后再来思考。。。】

2.8.3 用普通mutex替换读写锁的一个例子

#include <cassert>
#include <map>
#include <memory>
#include <mutex>
#include <string>
#include <vector>

using std::string;

class CustomerData {
 public:
  CustomerData() : data_(new Map) {}

  int query(const string& customer, const string& stock) const;

 private:
  CustomerData(const CustomerData&) = delete;
  CustomerData& operator=(const CustomerData&) = delete;

  typedef std::pair<string, int> Entry;
  typedef std::vector<Entry> EntryList;
  typedef std::map<string, EntryList> Map;
  typedef std::shared_ptr<Map> MapPtr;
  void update(const string& customer, const EntryList& entries);
  void update(const string& message);

  static int findEntry(const EntryList& entries, const string& stock);
  static MapPtr parseData(const string& message);

  MapPtr getData() const {
    std::lock_guard<std::mutex> lock(mutex_);
    return data_;
  }

  mutable std::mutex mutex_;
  MapPtr data_;
};

int CustomerData::query(const string& customer, const string& stock) const {
  MapPtr data = getData();

  Map::const_iterator entries = data->find(customer);
  if (entries != data->end())
    return findEntry(entries->second, stock);
  else
    return -1;
}

void CustomerData::update(const string& customer, const EntryList& entries) {
  std::lock_guard<std::mutex> lock(mutex_);
  if (!data_.unique()) {
    MapPtr newData(new Map(*data_));
    data_.swap(newData);
  }
  assert(data_.unique());
  (*data_)[customer] = entries;
}

void CustomerData::update(const string& message) {
  MapPtr newData = parseData(message);
  if (newData) {
    std::lock_guard<std::mutex> lock(mutex_);
    data_.swap(newData);
  }
}

int main() { CustomerData data; }

代码说明:

  • 关键看CustomerData::update()怎么写。既然要更新数据,那肯定得加锁,如果这时候其他线程正在读,那么不能在原来的数据上修改,得创建一个副本,在副本上修改,修改完了再替换。如果没有用户在读,那么就能直接修改,节约一次Map拷贝。
    注意其中用了shared_ptr::unique()来判断是不是有人在读,如果有人在读,那么我们不能直接修改,因为query()并没有全程加锁,只在getData()内部有锁。shared_ptr::swap()把data_替换为新副本,而且我们还在锁里,不会有别的线程来读,可以放心地更新。如果别的reader线程已经刚刚通过getData()拿到了MapPtr,它会读到稍旧的数据。这不是问题,因为我们假设数据更新来自网络,如果网络稍有延迟,反正reader线程也会读到旧的数据。
posted @ 2023-04-01 21:24  好人~  阅读(67)  评论(0编辑  收藏  举报