码迷,mamicode.com
首页 > 其他好文 > 详细

Chapter 2-01

时间:2016-04-30 06:37:49      阅读:233      评论:0      收藏:0      [点我收藏+]

标签:

Please indicate the source: http://blog.csdn.net/gaoxiangnumber1
Welcome to my github: https://github.com/gaoxiangnumber1
第2章 线程同步精要
?并发编程有两种基本模型,一种是message passing,另一种是shared memory。在分布式系统中,运行在多台机器上的多个进程的并行编程只有一种实用模型:message passing。在单机上,我们也可以用message passing作为多个进程的并发模型。这样整个分布式系统架构的一致性很强,扩容(scale out)起来也较容易。
?在多线程编程中,message passing更容易保证程序的正确性。不过在用C/C++编写多线程程序时,我们需要了解底层shared memory模型下的同步原语,以备不时之需。本章多次引用《Real World Concurrency》http://queue.acm.org/detail.cfm?id=1454462简称[RWC]。
?多线程编程教程:
?线程同步的四项原则,按重要性排列:
1.首要原则是尽量最低限度地共享对象,减少需要同步的场合。一个对象能不暴露给别的线程就不要暴露;如果要暴露,优先考虑immutable对象;实在不行才暴露可修改的对象,并用同步措施来充分保护它。
2.其次是使用高级的并发编程构件,如TaskQueue、Producer-Consumer Queue、CountDownLatch等等。
3.最后不得已必须使用底层同步原语(primitives)时,只用非递归的互斥器和条件变量,慎用读写锁,不要用信号量。
4.除了使用atomic整数之外,不自己编写lock-free代码3,也不要用“内核级”同步原语4 5。不凭空猜测“哪种做法性能会更好”,比如spin lock vs. mutex。

2.1 互斥器(mutex)
?互斥器(mutex)保护了临界区,任何一个时刻最多只能有一个线程在此mutex划出的临界区内活动。单独使用mutex时,主要为了保护共享数据。原则是:
1.用RAII手法封装mutex的创建、销毁、加锁、解锁这四个操作。即保证锁的生效期间等于一个作用域(scope),不会因异常而忘记解锁。
2.只用非递归的mutex(即不可重入的mutex)。
3.不手工调用lock()和unlock()函数,一切交给栈上的Guard对象的构造和析构函数负责。Guard对象的生命期正好等于临界区。这样我们保证始终在同一个函数同一个scope里对某个mutex加锁和解锁。避免在foo()里加锁,然后跑到bar()里解锁;也避免在不同的语句分支中分别加锁、解锁。这种做法被称为Scoped Locking7。

4.在每次构造Guard对象的时候,思考一路上(调用栈上)已经持有的锁,防止因加锁顺序不同而导致死锁(deadlock)。由于Guard对象是栈上对象,看函数调用栈就能分析用锁的情况,非常便利。
5.不使用跨进程的mutex,进程间通信只用TCP sockets。
6.加锁、解锁在同一个线程,线程a不能去unlock线程b已经锁住的mutex(RAII自动保证)。
7.别忘了解锁(RAII自动保证)。
8.不重复解锁(RAII自动保证)。
9.必要的时候可以考虑用PTHREAD_MUTEX_ERRORCHECK来排错。
2.1.1 只使用非递归的mutex
?mutex分为递归(recursive)和非递归(non-recursive)两种,这是POSIX的叫法,另外的名字是可重入(reentrant)与非可重入。这两种mutex作为线程间的同步工具时没有区别,唯一区别在于:同一个线程可以重复对recursive mutex加锁,但是不能重复对non-recursive mutex加锁。
?首选非递归mutex,不是为了性能,而是为了体现设计意图。non-recursive和recursive的性能差别不大,因为前者少用一个计数器,略快一点。在同一个线程里多次对non-recursive mutex加锁会立刻导致死锁,这是它的优点,能帮助我们思考代码对锁的期求,并且及早(在编码阶段)发现问题。
?recursive mutex使用方便,因为不用考虑一个线程会自己把自己给锁死了。但可能会隐藏代码里的一些问题。典型情况是你以为拿到一个锁就能修改对象了,没想到外层代码已经拿到了锁,正在修改(或读取)同一个对象。例子(recipes/thread/test/NonRecursiveMutex_test.cc)。

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();
    }
}

?post()加锁,然后修改foos对象;traverse()加锁,然后遍历foos向量。这些都是正确的。考虑Foo::doit()间接调用了post():
1.mutex是非递归的,于是死锁了。
2.mutex是递归的,由于push_back()可能(但不总是)导致vector迭代器失效,程序偶尔会crash。
?这时候能体现non-recursive的优越性:把程序的逻辑错误暴露出来。死锁容易debug,把各个线程的调用栈打出来8,只要每个函数不是特别长,容易看出来是怎么死的,见§2.1.2的例子9。或者可以用PTHREAD_MUTEX_ERRORCHEC找到错误(前提是MutexLock带debug选项)。

?如果确实需要在遍历的时候修改vector,有两种做法。
1.把修改推后,记住循环中试图添加或删除哪些元素,等循环结束了再依记录修改foos;
2.用copy-on-write,见§2.8的例子。
?如果一个函数既可能在已加锁的情况下调用,又可能在未加锁的情况下调用,那么就拆成两个函数:
1.跟原来的函数同名,函数加锁,转而调用第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),仿造§2.1.2的办法能比较容易地排错。
?对于(b),如果Pthreads提供 isLockedByThisThread()就好办,可以写成:

void postWithLockHold(const Foo& f)
{
assert(mutex.isLockedByThisThread());
// muduo::MutexLock提供了这个成员函数
// ...
}

?WithLockHold这个显眼的后缀也让程序中的误用容易暴露出来。
?Pthreads的权威专家,《Programming with POSIX Threads》的作者David Butenhof也排斥使用recursive mutex。他说10:
First, implementation of efficient and reliable threaded coder evolves around one simple and basic principle: follow your design. That implies, of course, that you have a design, and that you understand it.
A correct and well understood design does not require re-cursive mutexes.(后略)
?Linux的Pthreads mutex采用futex(2)实现11,不必每次加锁、解锁都陷入系统调用,效率不错。

2.1.2 死锁
?如果坚持只使用Scoped Locking,那么在出现死锁的时候很容易定位。考虑下面这个线程自己与自己死锁的例子(recipes/thread/test/SelfDeadLock.cc)。

class Request
{
public:
    void process() // __attribute__ ((noinline))
    {
        muduo::MutexLockGuard lock(mutex_);
        // ...
        print(); // L8 原本没有这行,某人为了调试程序不小心添加了。
    }

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

private:
    mutable muduo::MutexLock mutex_;
};

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

?添加L8之后,程序立刻出现了死锁。
?调试定位这种死锁很容易,只要把函数调用栈打印出来,结合源码一看,立刻就会发现第6帧Request::process()和第5帧Request::print()先后对同一个mutex上锁,引发了死锁。(必要的时候可以加上attribute来防止函数inline展开

$ gdb ./self_deadlock core
(gdb) bt
#0 __lll_lock_wait () at ../nptl/sysdeps/unix/sysv/linux/x86_64/lowlevellock.S:136
#1 _L_lock_953 () from /lib/libpthread.so.0
#2 __pthread_mutex_lock (mutex=0x7fffecf57bf0) at pthread_mutex_lock.c:61
#3 muduo::MutexLock::lock () at test/../Mutex.h:49
#4 MutexLockGuard () at test/../Mutex.h:75
#5 Request::print () at test/SelfDeadLock.cc:14
#6 Request::process () at test/SelfDeadLock.cc:9
#7 main () at test/SelfDeadLock.cc:24

?修复这个错误很容易,从Request::print()抽取出Request::printWithLockHold(),并让Request::print()和Request::process()都调用它即可。
?一个两个线程死锁的例子(recipes/thread/test/MutualDeadLock.cc)。
?有一个Inventory(清单)class,记录当前的Request对象。Inventory的add()和 remove()成员函数都是线程安全的,它使用了mutex来保护共享数据requests_。

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;

private:
    mutable muduo::MutexLock mutex_;
    std::set<Request*> requests_;
};
Inventory g_inventory; // 为了简单起见,这里使用了全局对象。

?Request class在处理(process)请求的时候,往g_inventory中添加自己。在析构的时候,从g_inventory中移除自己。

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_;
};

?Inventory class能打印全部已知的Request对象。Inventory::printAll()的逻辑单独看没问题,但有可能引发死锁。

void Inventory::printAll() const
{
    muduo::MutexLockGuard lock(mutex_);
    sleep(1); // 为了容易复现死锁,这里用了延时
    for(    std::set<Request*>::const_iterator it=requests_.begin();
it !=requests_.end(); ++it)
    {
        (*it)->print();
    }
    printf("Inventory::printAll() unlocked\n");
}

?下面这个程序运行起来发生了死锁:

void threadFunc()
{
    Request* req = new Request;
    req->process();
    delete req;
}
int main()
{
    Thread thread(threadFunc);
    thread.start();
    usleep(500 * 1000);  // 为了让另一个线程等在前面第14行的sleep()上。
    g_inventory.printAll();
    thread.join();
}

?通过gdb查看两个线程的函数调用栈,我们发现两个线程都等在mutex上(__lll_lock_wait),估计是发生了死锁。因为一个程序中的线程一般只会等在condition variable上,或者等在epoll_wait上(§3.5.3)。

$ gdb ./mutual_deadlock core
(gdb) thread apply all bt
Thread 1 (Thread 31229): # 这是 main() 线程
#0 __lll_lock_wait () at ../nptl/sysdeps/unix/sysv/linux/x86_64/lowlevellock.S:136
#1 _L_lock_953 () from /lib/libpthread.so.0
#2 __pthread_mutex_lock (mutex=0xecd150) at pthread_mutex_lock.c:61
#3 muduo::MutexLock::lock (this=0xecd150) at test/../Mutex.h:49
#4 MutexLockGuard (this=0xecd150) at test/../Mutex.h:75
#5 Request::print (this=0xecd150) at test/MutualDeadLock.cc:51
#6 Inventory::printAll (this=0x605aa0) at test/MutualDeadLock.cc:67
#7 0x0000000000403368 in main () at test/MutualDeadLock.cc:84
Thread 2 (Thread 31230): # 这是 threadFunc() 线程
#0 __lll_lock_wait () at ../nptl/sysdeps/unix/sysv/linux/x86_64/lowlevellock.S:136
#1 _L_lock_953 () from /lib/libpthread.so.0
#2 __pthread_mutex_lock (mutex=0x605aa0) at pthread_mutex_lock.c:61
#3 muduo::MutexLock::lock (this=0x605aa0, req=0x80) at test/../Mutex.h:49
#4 MutexLockGuard (this=0x605aa0, req=0x80) at test/../Mutex.h:75
#5 Inventory::remove (this=0x605aa0, req=0x80) at test/MutualDeadLock.cc:19
#6 ~Request (this=0xecd150, ...) at test/MutualDeadLock.cc:46
#7 threadFunc () at test/MutualDeadLock.cc:76
#8 boost::function0<void>::operator() (this=0x7fff21c10310) at  /usr/include/boost/function/function_template.hpp:1013
#9 muduo::Thread::runInThread (this=0x7fff21c10310) at Thread.cc:113
#10 muduo::Thread::startThread (obj=0x605aa0) at Thread.cc:105
#11 start_thread (arg=<value optimized out>) at pthread_create.c:300
#12 clone () at ../sysdeps/unix/sysv/linux/x86_64/clone.S:112

?注意到main()线程是先调用Inventory::printAll(#6)再调用Request::print(#5),而threadFunc()线程是先调用Request::~Request(#6)再调用Inventory::remove(#5)。这两个调用序列对两个mutex的加锁顺序正好相反,造成了死锁。
?Inventory class的mutex的临界区由灰底表示,Request class的mutex的临界区由斜纹表示。一旦main()线程中的printAll()在另一个线程的~Request()和remove()之间开始执行,死锁不可避免。

?思考:如果printAll()晚于remove()执行,还会出现死锁吗?
?练习:修改程序,让~Request()在printAll()和print()之间开始执行,复现另一种可能的死锁时序。这里也出现了第1章所说的对象析构的race condition,即一个线程正在析构对象,另一个线程却在调用它的成员函数。
?解决死锁的办法很简单,要么把print()移出printAll()的临界区,这可以用§2.8介绍的办法;要么把remove()移出~Request()的临界区,比如交换§2.1.2中L13和L15两行代码的位置。当然这没有解决对象析构的race condition,留做练习。
?思考:Inventory::printAll→Request::print有无可能与Request::process→Inventory::add发生死锁?
?死锁会让程序行为失常,其他一些锁使用不当则会影响性能,例如潘爱民老师写的《Lock Convoys Explained》15详细解释了一种性能衰退的现象。除此之外,编写高性能多线程程序至少还要知道false sharing和CPU cache效应,可看脚注中的这几篇文章16 17 18 19。

Please indicate the source: http://blog.csdn.net/gaoxiangnumber1
Welcome to my github: https://github.com/gaoxiangnumber1

Chapter 2-01

标签:

原文地址:http://blog.csdn.net/gaoxiangnumber1/article/details/51285002

(0)
(0)
   
举报
评论 一句话评论(0
登录后才能评论!
© 2014 mamicode.com 版权所有  联系我们:gaon5@hotmail.com
迷上了代码!