Linux(muduo网络库):03---线程同步概要之(互斥器、条件变量、读写锁、信号量、sleep)

  • 并发编程有两种基本模型:一种是message passing,另一种是shared memory
    • 在分布式系统中,运行在多台机器上的多个进程的并行编程只有一种实用模型:message passing
    • 在单机上,我们也可以照搬message passing作为多个进程的并发模型
    • 这样整个分布式系统的架构的一致性很强,扩容(scale out)起来也较容易
    • 在多线程编程中, message passing更容易保证程序的正确性,有的语言只提供这一种模型。不过在用C/C++编写多线程程序时,我们仍然需要了解底层的shared memory模型下的同步原语,以备不时之需
  • 本章不是多线程教程,而是分享一些C++多线程编程的经验。本章多次引用《Real-World Concurrency》一文的观点,这篇文章的地址是https://queue.acm.org/detail.cfm?id=1454462 ,后文简称[RWC]
  • 线程同步的四项原则,按重要性排列:
    • ①首要原则是尽量最低限度地共享对象,减少需要同步的场合。 一个对象能不暴露给别的线程就不要暴露;如果要暴露,优先考虑 immutable对象;实在不行才暴露可修改的对象,并用同步措施来充分 保护它
    • ②其次是使用高级的并发编程构件,如TaskQueue、ProducerConsumer Queue、CountDownLatch等等
    • ③最后不得已必须使用底层同步原语(primitives)时,只用非递归的互斥器和条件变量,慎用读写锁,不要用信号量
    • ④除了使用atomic整数之外,不自己编写lock-free代码,也不要用“内核级”同步原语。不凭空猜测“哪种做法性能会更好”,比如spin lock vs. mutex
    • 前面两条很容易理解,这里着重讲一下第③条:底层同步原语的使用
  • 互斥器、条件变量、读写锁等语法,可以参阅本人的“UNIX(编程-线程处理)”专栏:https://blog.csdn.net/qq_41453285/category_8857234.html

一、互斥器

  • 互斥器(mutex)是使用得最多的同步原语,粗略地说,它保护了临界区,任何一个时刻最多只能有一个线程在此mutex划出的临界 区内活动。单独使用mutex时,我们主要为了保护共享数据
  • 性能:
    • Linux的Pthreads mutex采用futex实现(参阅:https://akkadia.org/drepper/futex.pdf),不必每次加 锁、解锁都陷入系统调用,效率不错
    • Windows的CRITICAL_SECTION也是类似的,不过它可以嵌入一小段spin lock。在多CPU系统上,如果不能立刻拿到锁,它会先spin一小段时间,如果还不能拿到锁,才挂起当前线程

互斥器使用的一些原则

  • 我个人的原则是:
    • 用RAII手法封装mutex的创建、销毁、加锁、解锁这四个操作用 RAII封装这几个操作是通行的做法,这几乎是C++的标准实践,后面会给出具体的代码示例,相信大家都已经写过或用过类似的代码了。 Java里的synchronized语句和C#的using语句也有类似的效果,即保证锁的生效期间等于一个作用域(scope),不会因异常而忘记解锁
    • 只用非递归的mutex(即不可重入的mutex)
    • 不手工调用lock()和unlock()函数,一切交给栈上的Guard对象的构造和析构函数负责。Guard对象的生命期正好等于临界区(分析对象在什么时候析构是C++程序员的基本功)。这样我们保证始终在同一个函数同一个scope里对某个mutex加锁和解锁。避免在foo()里加锁,然后跑到bar()里解锁;也避免在不同的语句分支中分别加锁、解锁。这种做法被称为Scoped Locking(参阅Douglas Schmidt的论文:www.cs.wustl.edu/~schmidt/PDF/locking-patterns.pdf
    • 在每次构造Guard对象的时候,思考一路上(调用栈上)已经持有的锁,防止因加锁顺序不同而导致死锁(deadlock)。由于Guard对象是 栈上对象,看函数调用栈就能分析用锁的情况,非常便利
  • 次要原则有:
    • 不使用跨进程的mutex,进程间通信只用TCP sockets
    • 加锁、解锁在同一个线程,线程a不能去unlock线程b已经锁住的 mutex(RAII自动保证)
    • 别忘了解锁(RAII自动保证)
    • 不重复解锁(RAII自动保证)
    • 必要的时候可以考虑用PTHREAD_MUTEX_ERRORCHECK来排错
  •  mutex恐怕是最简单的同步原语,按照上面的几条原则,几乎不可能用错

只使用非递归的mutex

  • mutex分为:
    • 递归(recursive),这是POSIX的叫法。其他叫法为可重入(reentrant)
    • 非递归(non-recursive),这是POSIX的叫法。其他叫法为非可重入
  • 这两种mutex作为线程间(inter-thread)的同步工具时没有区别,它们的唯一区别在于:同一个线程可以重复对recursive mutex加锁,但是不能重复对non-recursive mutex加锁
  • 首选非递归mutex,绝对不是为了性能,而是为了体现设计意图:
    • non-recursive和recursive的性能差别其实不大,因为少用一个计数器,前者略快一点点而已
    • 在同一个线程里多次对non-recursive mutex加锁会立刻导致死锁,我认为这是它的优点能帮助我们思考代码对锁的期 求,并且及早(在编码阶段)发现问题
    • 毫无疑问recursive mutex使用起来要方便一些,因为不用考虑一个线程会自己把自己给锁死了,我猜这也是Java和Windows默认提供recursive mutex的原因。(Java语言自带的intrinsic lock是可重入的,它的util.concurrent库里提供ReentrantLock,Windows的 CRITICAL_SECTION也是可重入的。似乎它们都不提供轻量级的nonrecursive mutex)
    • 正因为它方便,recursive mutex可能会隐藏代码里的一些问题。典型情况是你以为拿到一个锁就能修改对象了,没想到外层代码已经拿到了锁,正在修改(或读取)同一个对象呢

演示案例

  • post()加锁,然后修改foos对象;traverse()加锁,然后遍历foos向量。这些都是正确的
  • 将来有一天,Foo::doit()间接调用了post(),那么会很有戏剧性的结果:
    • mutex是非递归的,于是死锁了
    • mutex是递归的,由于push_back()可能(但不总是)导致vector迭代器失效,程序偶尔会crash
MutexLock mutex;
std::vector<Foo> foos;

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

void traverse()
{
    MutexLockGuard lock(&mutex);
    for (std::vector<Foo>::const_iterator it = foos.begin();
            it != foos.end(); ++it)
    {
        it->doit();
    }
}
  • 这时候就能体现non-recursive的优越性:
    • 把程序的逻辑错误暴露出来
    • 死锁比较容易debug:
      • 把各个线程的调用栈打出来(gdb中使用thread apply all bt命令),只要每个函数不是特别长,很容易看出来是怎么死的(见下面的“死锁”案例,另一方面支持了“函数不要写的过长”这一观点)
      • 或者可以用PTHREAD_MUTEX_ERRORCHECK一下子就能找到错误(前提是MutexLock带debug选项)
    • 程序反正要死,不如死得有意义一点,留个“全尸”,让验尸(post-mortem)更容易些
  • 针对上面的程序,如果确实需要在遍历的时候修改vector,有两种做法:
    • 一是把修改推后,记住循环中试图添加或删除哪些元素,等循环结束了再依记录修改foos
    • 二是用copy-on-write(见后面文章介绍)
  • 如果一个函数既可能在已加锁的情况下调用,又可能在未加锁的情况下调用,那么就拆成两个函数:
    • ①跟原来的函数同名,函数加锁,转而调用第2个函数
    • ②给第2个函数名加上后缀WithLockHold,不加锁,把原来的函数体搬过来
    • 就像下面这样这样:
//线程未加锁时,调用这个函数
void post(const Foo& f)
{
    MutexLockGuard lock(&mutex);
    postWithLockHold(f);
}

//线程加锁之后,不调用上面那个函数,而调用这个函数
void postWithLockHold(const Foo& f)
{
    foos.push_back(f);
}
  • 这有可能出现两个问题:
    • (a)误用了加锁版本,死锁了
    • (b)误用了不加锁版本,数据损坏了
    •  对于(a),仿造下面将要介绍的“死锁”的办法能比较容易地排错。对于(b),如果Pthreads提供isLockedByThisThread()就好办,可以写成:
void postWithLockHold(const Foo& f)
{
    //MutexLock提供了这个成员函数,在后面文章会介绍
    assert(mutex.isLockedByThisThread());
    //...
}
  • 另外,WithLockHold这个显眼的后缀也让程序中的误用容易暴露出来
  • C++没有annotation,不能像Java那样给method或field标上 @GuardedBy注解,需要程序员自己小心在意。虽然这里的办法不能一 劳永逸地解决全部多线程错误,但能帮上一点是一点了

死锁

演示案例①

  • 下面是一个自己将自己锁住的例子:
//摘录于recipes/thread/test/SelfDeadLock.cc
class Request
{
public:
    void process() // __attribute__ ((noinline))
    {
        muduo::MutexLockGuard lock(mutex_);
        //...
        print(); //标记1。为了调试,加入这一个函数
    }

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

private:
    mutable muduo::MutexLock mutex_;
};

int main()
{
    Request req;
    req.process();
}
  • 上面代码的标记1处,调用print会产生死锁
  •  要调试死锁很容易,只要把函数调用栈打印出来,结合源码一看:
    • 第6帧Request::process()和第5帧Request::print()先后对同一个mutex上锁,引发了死锁
    • 必要的时候可以加上“__attribute__ ((noinline))”来防止函数inline展开

  • 解决上面那个死锁的问题很简单,就是按照上面介绍的拆分函数的方法:
    • 从Request::print()抽取出Request::printWithLockHold()
    • 并让Request::print()和Request::process()都调用Request::printWithLockHold()即可
class Request
{
public:
    void process(){
        muduo::MutexLockGuard lock(mutex_);
        //...
        printWithLockHold(); //替换print
    }
    void print() const{
        muduo::MutexLockGuard lock(mutex_);
        printWithLockHold();
    }
    void printWithLockHold(){
        //将原本print()中完成的功能移动到此处
    }
private:
    mutable muduo::MutexLock mutex_;
};

演示案例②

  • 下面是一个Inventory(清单)class的定义:
    • 其记录当前的Request对象
    • 其中的add()和remove()成员函数都是线程安全的,它使用了mutex来保护共享数据requests_
    • printAll()用来打印全部已知的Request对象
class Inventory
{
public:
    void add(Request* req)
    {
        muduo::MutexLockGuard lock(mutex_);
        requests_.insert(req);
    }
    void remove(Request* req) // __attribute__ ((noinline))
    {
        muduo::MutexLockGuard lock(mutex_);
        requests_.erase(req);
    }
    void printAll()const
    {
        muduo::MutexLockGuard lock(mutex_);
        sleep(1); //为了容易复现死锁,这里用了延时
        for (std::set<Request*>::const_iterator it = requests_.begin();
                it != requests_.end(); ++it)
        {
            (*it)->print();
        }
    }
private:
    mutable muduo::MutexLock mutex_;
    std::set<Request*> requests_;
};
  • 我们定义一个全局对象Inventory对象:
//为了简单起见,使用全局对象
Inventory g_inventory;
  • 下面是Request class的定义,其与Invenroty class的交互逻辑很简单:
    • 在处理请求(process)的时候,往g_inventory中添加自己
    • 在析构的时候,从g_inventory中移除自己
    • print()用来打印当前对象
class Request
{
public:
    void process() // __attribute__ ((noinline))
    {
        muduo::MutexLockGuard lock(mutex_);
        g_inventory.add(this);
    }

    ~Request __attribute__((noinline))
    {
        muduo::MutexLockGuard lock(mutex_);
        sleep(1); //为了容易复现死锁,这里用了延时
        g_inventory.remove(this);
    }

    void print()const __attribute__((noinline))
    {
        muduo::MutexLockGuard lock(mutex_);
        //...
    }
private:
    mutable muduo::MutexLock mutex_;
};
  •  Request::printAll()的逻辑单独看是没问题的,但是它有可能会引发死锁。例如:
//线程执行函数
void threadFunc()
{
    //使用g_inventory处理req请求,函数执行完成之后释放req
    Request* req = new Request;
    req->process();
    delete req;
}

int main()
{
    muduo::Thread thread(threadFunc);
    thread.start();
    
    //为了让另一个线程等在Request析构函数中的sleep()上
    //单位为微秒,此处为0.5秒
    usleep(500 * 1000);
    g_inventory.printAll();

    thread.join();
    return 0;
}
  • 通过gdb查看两个线程的函数调用栈,我们发现:
    • 两个线程都等在mutex上(__lll_lock_wait),估计是发生了死锁
    • 因为一个程序中的线程一般只会等在condition variable(条件变量)上,或者等在epoll_wait上

  • 通过上图可以看到:
    • main()线程先调用Inventory::printAll(),再调用Request::print()
    • threadFunc()线程是先调用Request::~Request(),再调用Inventory::remove()
    • 这两个线程调用序列对两个mutex的加锁顺序正好相反,于是造成了经典的死锁
    • 见下图,Inventory class的mutex的临界区由灰底表示,Request class的mutex的临界区由斜纹表示。一旦main()线程中的printAll()在另一个线程的~Request()和remove()之间开始执行,死锁已不可避免

  • 这里也出现了前面文章所介绍的对象析构的race condition:即一个线程正在析构对象,另一个线程却在调用它的成员函数
  • 解决死锁的办法很简单:
    • 要么把print()移除printAll()的临界区,这可以用后面介绍的“借shared_ptr实现copy-on-write”技术
    • 要么把remove()移出~Request()的临界区,比如将~Request()中的sleep()函数的前后两个语句进行交换
    • 当然还没有解决对象析构的race condition,留给读者自己解决
  • 思考问题:
    • 如果printAll()晚于remove()执行,还会出现死锁吗?
    • Inventory::printAll→Request::print有没有可能与 Request::process→Inventory::add发生死锁?

二、条件变量

  • 条件变量概述与互斥器的比较:
    • 互斥器(mutex)是加锁原语,用来排他性地访问共享数据,它不是等待原语。在使用mutex的时候,我们一般都会期望加锁不要阻塞, 总是能立刻拿到锁。然后尽快访问数据,用完之后尽快解锁,这样才能不影响并发性和性能
    • 如果需要等待某个条件成立,我们应该使用条件变量(condition variable)。条件变量顾名思义是一个或多个线程等待某个布尔表达式为真,即等待别的线程“唤醒”它。条件变量的学名叫管程(monitor)。 Java Object内置的wait()、notify()、notifyAll()是条件变量(Java的这三个函数以容易用错著称,一般建议用java.util.concurrent中的同步原语)
    • 互斥器和条件变量构成了多线程编程的全部必备同步原语,用它们即可完成任何多线程同步任务,二者不能相互替代。我认为应该精通这两个同步原语的用法,先学会编写正确的、安全的多线程程序,再在必要的时候考虑用其他“高技术”手段提高性能,如果确实能提高性能的 话。千万不要连mutex都还没学会、用好,一上来就考虑lock-free设计(参阅:http://www.drdobbs.com/cpp/lock-code-a-false-sense-of-security/210600279

条件变量的正确使用方法(附BlockingQueue<T>演示案例)

  • 条件变量只有一种正确使用的方式,几乎不可能用错
  • 对于wait()端来说:
    • ①必须与mutex一起使用,该布尔表达式的读写需受此mutex保护
    • ②在mutex已上锁的时候才能调用wait()
    • ③把判断布尔条件和wait()放到while循环中
  • wait()端示例代码如下:
muduo::MutexLock mutex;
muduo Condition cond(mutex);
std::deque<int> queue;

//出队列
int dequeue()
{
    MutexLockGuard lock(mutex);

    //必须使用循环,在判断之后再wait()
    while (queue.empty())
    {
        //这一步原子地unlock mutex并进入等待,不会与enqueue()函数死锁
        cond.wait();
        //wait()执行完毕之后自动重新加锁
    }
    
    //下面是出队列的操作
    assert(!queue.empty());
    int top = queue.front();
    queue.pop_front();
    return top;
}
  • 对于signal/broadcast端来说:
    • ①不一定要在mutex已上锁的情况下调用signal(理论上)
    • ②在signal之前一般要修改布尔表达式
    • ③修改布尔表达式通常要用mutex保护(至少用作full memory barrier)
    • ④注意区分signal与broadcast:“broadcast通常用于表明状态变化, signal通常用于表示资源可用。(broadcast should generally be used to indicate state change rather than resource availability)([RWC]:“Know when to broadcast-and when to signal”)
  • signal/broadcast端示例代码如下:
muduo::MutexLock mutex;
muduo Condition cond(mutex);
std::deque<int> queue;

//入队列
void enqueue(int x)
{
    MutexLockGuard lock(mutex);
    queue.push_back(x);
    cond.notify(); //可以移出临界区之外
}
  • 思考:
  • 总结:
    • muduo::Condition采用了notify()和notifyAll()为函数名,避免重载signal这个术语
    • 上面的dequeue()/enqueue()实际上实现了一个简单的容量无限的(unbounded)BlockingQueue(附加:实际使用时一般会做成类模板,如muduo/base/BlockingQueue.h)

CountDownLatch

  • 条件变量是非常底层的同步原语,很少直接使用,一般都是用它来实现高层的同步措施,如:
    • BlockingQueue:已经在上面演示过了
    • CountDownLatch(倒计时):是一种常用且易用的同步手段。代码可以参阅(muduo/base/CountDownLatch{h,cc})
  • CountDownLatch它主要有两种用途:
    • ①主线程发起多个子线程,等这些子线程各自都完成一定的任务之后,主线程才继续执行。通常用于主线程等待多个子线程完成初始化
    • ②主线程发起多个子线程,子线程都等待主线程,主线程完成其他 一些任务之后通知所有子线程开始执行。通常用于多个子线程等待主线 程发出“起跑”命令
    • 当然我们可以直接用条件变量来实现以上两种同步,不过如果用CountDownLatch的话,程序的逻辑更清晰
  • CountDownLatch的接口很简单,实现也很简单,几乎就是条件变量的教科书式应用
class CountDownLatch :boost::noncopyable
{
public:
    explicit CountDownLatch(int count);
    void wait();       //等待计数值变为0
    voiud countDown(); //计数减1
private:
    mutable MutexLock mutex_; //顺序很重要,先mutex后condition
    Condition condition_;
    int count_;
};

void CountDownLatch::wait()
{
    MutexLockGuard lock(mutex_);
    while (count_ > 0)
        condition_.wait();
}

void CountDownLatch::countDown()
{
    MutexLockGuard lock(mutex_);
    --count_;
    if (count_ == 0)
        condition_.notifyAll();
}
  • 注意事项:
    • 注意到CountDownLatch::countDown()使用的是Condition::notifyAll(),而前面此处的enqueue()使用的是Condition::notify(),这都是有意为之
    • 请读者思考,如果交换两种用法会出现什么情况?

三、不要使用读写锁和信号量

不要使用读写锁

  • 读写锁(rwlock)是个看上去很美的抽象,它明确区分了read和write两种行为
  • 初学者常干的一件事情是,一见到某个共享数据结构频繁读而很少写,就把mutex替换为rwlock。甚至首选rwlock来保护共享状态,这不见得是正确的([RWC]:“Be wary of readers/writer locks”)
  • 一些说明:
    • 从正确性方面来说,一种典型的易犯错误是在持有read lock的时候修改了共享数据。这通常发生在程序的维护阶段,为了新增功能,程序员不小心在原来read lock保护的函数中调用了会修改状态的函数。这种错误的后果跟无保护并发读写共享数据是一样的
    • 从性能方面来说,读写锁不见得比普通mutex更高效。无论如何reader lock加锁的开销不会比mutex lock小,因为它要更新当前reader的 数目。如果临界区很小(附:在多线程编程中,我们总是设法缩短临界区,不是吗?),锁竞争不激烈,那么mutex往往会更快。见前面文章(“再论shared_ptr的线程安全”的例子)
    • reader lock可能允许提升(upgrade)为writer lock,也可能不允许提升(Pthreads rwlock不允许提升)。考虑文章开始讨论的post()和traverse()示例,如果用读写锁来保护foos对 象,那么post()应该持有写锁,而traverse()应该持有读锁。如果允许把读锁提升为写锁,后果跟使用recursive mutex一样,会造成迭代器失效, 程序崩溃。如果不允许提升,后果跟使用non-recursive mutex一样,会 造成死锁。我宁愿程序死锁,留个“全尸”好查验
    • 通常reader lock是可重入的,writer lock是不可重入的。但是为了防止writer饥饿,writer lock通常会阻塞后来的reader lock,因此reader lock 在重入的时候可能死锁。另外,在追求低延迟读取的场合也不适用读写锁(参阅后面介绍的“使用shared_ptr实现copt-on-write”文章)
  • 附加:
    • muduo线程库有意不提供读写锁的封装,因为我还没有在工作中遇到过用rwlock替换普通mutex会显著提高性能的例子。相反,我们一般建议首选mutex
    • 遇到并发读写,如果条件合适,我通常会用“使用shared_ptr实现copt-on-write”技术(后面文章介绍),而不用读写锁,同时避免reader被writer阻塞
    • 如果确实对并发读写有极高的性能要求,可以考虑read-copy-update(参阅:http://en.wikipedia.org/wiki/Read-copy-update

不要使用信号量

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

四、sleep不是同步原语

  • 一些说明:
    • 我认为sleep()/usleep()/nanosleep()只能出现在测试代码中,比如写单元测试的时候(备注:设计时间的单元测试不那么好写,短的如一两秒可以用sleep();长的如一小时、一天,则得想其他办法,比如把算法提取出来并把时间注入进去)
    • 或者用于有意延长临界区,加速复现死锁的情况,就像“前文介绍的死锁”示范的那样
    • sleep不具备memory barrier语义,它不能保证内存的可见性(参阅后面“C++多线程系统编程精要”的文章最开始的例子)
  • 生产代码中线程的等待可分为两种:
    • 一种是等待资源可用(要么等在select/poll/epoll_wait上,要么等在条件变量上,我们自己设计的“等待BlockingQueue/CountDownLatch”也可归入此处)
    • 一种是等着进入临界区(等在mutex上)以便读写共享数据。这一种等待通常极短,否则程序性能和伸缩性就会有问题
  • 在程序的正常执行中:
    • 如果需要等待一段已知的时间,应该往event loop里注册一个timer,然后在timer的回调函数里接着干活,因为线程是个珍贵的共享资源,不能轻易浪费(阻塞也是浪费)
    • 如果等待某个事件发生,那么应该采用条件变量或IO事件回调,不能用sleep来轮询
    • 不要使用下面这种业余做法:
while (true)
{
    if (!dataAvailable)
        sleep(some_time);
    else
        consumeDta();
}
  • 总结:
    • 如果多线程的安全性和效率要靠代码主动调用sleep来保证,这显然是设计出了问题
    • 等待某个事件发生,正确的做法是用select()等价物或Condition,抑或(更理想地)高层同步工具
    • 在用户态做轮询 (polling)是低效的

五、总结

  • 线程同步的四项原则,尽量用高层同步设施(线程池、队列、倒计时)
  • 使用普通互斥器和条件变量完成剩余的同步任务,采用RAII惯用手法(idiom)和Scoped Locking。这一点在后面一篇文章介绍

六、附加

发布了1525 篇原创文章 · 获赞 1084 · 访问量 45万+

猜你喜欢

转载自blog.csdn.net/qq_41453285/article/details/104859230