【读书笔记】"Real-world Concurrency"论文笔记

来源:互联网 发布:数据采集器的作用 编辑:程序博客网 时间:2024/05/23 16:56
        最近读到一篇ACM Queue收录的文章—Real-World Concurrency,文中主要讨论了编写并发程序的主要原则,精读之后收获颇大,故作为笔记,记录于此。

1. Why Concurrent
        并发执行以3种方式提升系统性能:
        1) reduce latency
        原文:make a unit of work execute faster 
        详解:对于一些任务(如科学计算或海量数据处理),若其容易被分割成更小任务单元(子任务间无共享资源或状态依赖),并发执行的优势极其明显,MapReduce就是这种并发情况的典型解决框架。
        2) hide lantency
        原文:allow the system to continue doing work during a long-latency operation 
        详解:IO密集型系统尤其明显。另外,并发执行耗时操作并非隐藏系统延时的唯一途径,异步IO接口或事件池(select/poll/epoll)的合理使用也能达到相同的效果。
        3) increase throughout
        原文:make the system able to perform more work 
        详解:是前2条的必然结果,IO密集型系统尤其明显。
        值得注意的是:通过并发来提升系统吞吐性能并不要求必须用多线程实现。具体而言:可以将系统中无共享状态的部分抽象成独立模块仍以串行方式执行,只需同时执行多个实例即可实现并发;而对于系统中需共享的资源(比如数据),可以将其放置到专门为共享状态的并发执行而设计的模块(如数据库)中。这种并发是通过架构而非业务代码实现的,我认为是设计系统时应该追求的优雅方案。

2. Tricks for writing multithreaded code
        实现无错高效的并发程序是公认的难点,针对实现底层系统的开发者,文中给出了编写多线程程序的15条建议,分别如下所列。
        1) Know your cold paths from your hot paths
        所谓"hot paths"是指会被频繁执行以至于可能成为系统瓶颈的代码段落(如模块的核心逻辑或循环体);类似地,"cold paths"指只会执行有限次的代码段(如系统启动时读配置等的初始化代码)。
        针对hot paths,可按业务需求尽量并发执行;而追求cold paths的并发执行不但会在复杂的编码实现上浪费时间,而且这类代码段也是容易引入bug的地方。作者的建议是"In cold paths, keep the locking as coarse-grained as possible",即在cold paths上可以尽管加粗粒度的锁而不要担心其性能问题;而在hot paths上加锁时,需要特别注意锁的粒度。
        如何判断某代码段的热度?作者的建议是可以先将其当作cold paths处理,后续出现性能瓶颈时,再做针对性优化。这也与“程序不要过早优化”(Donald Knuth: "We should forget about small efficiencies, say about 97% of the time: premature optimization is the root of all evil"  )的原则相一致。
        2) Intuition is frequently wrong - be data intensive
        程序员凭直觉判断代码段的hot or cold通常会出现偏差。凭直觉判断代码段的热度在开发阶段是可行的,但一旦软件已经可运行或者已经其原型已经实现,则需要让数据说话。具体而言,就是对可运行的模块做并发测试(压测)以便收集运行时的统计数据,从而指导后续的优化工作。
        备注:文中提到的DTrace是一个动态追踪框架,用于收集运行时数据以便帮助我们分析程序行为。
        3) Know when and when not to break up a lock
        保持并发程序高性能的最理想情况是无锁或把锁拆成更小的锁(如把对整个hash-table的锁拆成针对htable中每个冲突链的锁(pre-chain locks)),但考虑到实现代价,拆解锁粒度并非减少多线程竞争的理想途径。事实上,通过优化lock保护的关键代码段,可以极大地减少竞争。这可以通过改进算法(如优化其时间复杂度)或将没有必要保护的代码移至lock的作用域之外来实现。
        关于将没必要在lock作用域执行的代码(通常是耗时操作)移出保护区的典型例子,原文中有描述,限于篇幅,这里不再赘述。
         4) Be wary of readers/writer locks
        典型误区:若某个读多写少的数据结构原来以mutex保护,则很多人会用rwlock来代替mutex。这在每次读/写持锁时间较长的场合下是合理的,但在R/W耗时很短的场合,用rwlock代替mutex不但无助于性能提升,甚至还会降低性能。
       原因分析:根据文章-Spinlocks and Read-Write Locks(可能需翻墙)的剖析可知:rwlock的实现原理是其内部维护reader的引用计数,而这个refcount在内存单元中占据1个字,每次申请rdlock时,reader的refcount必须保证原子更新。因此,多个reader同时申请rdlock时,更新那个内存单元的bus会非常繁忙以至于最终形成性能瓶颈。
        5) Consider per-CPU locking
        基本思路是将全局锁拆分为对每个CPU加锁。但该思路有两个点需注意:a. 当且仅当性能数据显示有必要这个拆锁时才应该考虑这样实现(因为实现代价很大);b. 需确保在cold paths上获取/释放这些per-CPU locks的顺序要一致,否则必然出现deadlock。
        6) Know when to broadcast and when to signal
        当多个线程阻塞在某个变量处等待唤醒时,需要斟酌是以广播方式还是以signal方式唤醒线程:broadcast会唤醒所有的等待线程,所以它仅适用于通知系统状态变化(be used to indicate state change)给各线程,而signal方式适用于通知各线程等待的资源目前可用(be used to indicate resource availability)。若在signal方式更合适的场合下误用broadcast方式,则系统会出现惊群效应(Thundering herd problem),这会对系统性能造成明显的负面影响。
        7) Learn to debug post postmortem
        并发程序出现死锁的频率较高,要调试死锁问题,需提供死锁发生时的threads list、各thread的stack backtrace,另外还需一些操作系统底层知识,而进程的core dump可以提供这些信息。故掌握core dump的调试技巧是必需技能。
        8) Design your systems to be composable
        术语composability的含义可以参考这里。本规则要求设计系统时,要保证组成整个系统的subsystems是composable的。
        文中给出了保证lock-based system具有composable的2条设计原则:
         a. 锁操作需完全封装在subsystem内部,不应暴漏给其它subsystems;
         b. 消除global subsystem state且subsystem的consumers要保证它们不会并发访问subsystem的实例,这样可用lock-free的方式实现composability。文中用Solaris kernel中AVL树的实现来对第2条原则做了解释。
        9) Don't use a semaphore where a mutex would suffice
        semaphore和mutex都是常用的线程同步机制,但从调试角度来看,它们有个显著的不同点:mutex具有ownership信息,即一个mutex实例只有被持有或未被持有两种状态,但它被持有时,owner是可知的;与此相反,semaphore(或者condition variable)无ownership的概念,当线程阻塞等待semaphore状态变化时,无法获取哪个线程将会"消费"或"持有"最近将要变化的semaphore状态。
         对于临界区(critical section)的保护来说,缺失ownership信息会带来几个问题:
          a. 处于临界区的线程无法获得被同步机制阻塞的线程的调度优先级信息。对于realtime系统来说,这意味者某些具有低延时要求的线程无法得到优先调度。
          b. 使得系统无法对自身运行状态做评估。例如,若ownership可跟踪,则系统在实现thread blocking时可以检测某些异常操作(如deadlock或递归锁),而semaphore使得系统无法实现这种跟踪。
          c. 增加了调试难度。假设代码的某异常分支未释放锁就直接return,若由此造成程序行为异常,则在系统无法跟踪ownership的情况下,我们无法通过现场的backtrace信息定位bug,只能从头开始调试。
        10) Consider memory retiring to implement per-chain hash-table locks
        当需要并发访问hash table时,一种高效方法是用rwlock对hashtable的每个chain做防护。假设ht需要动态rehash,在不想引入针对整个ht的全局锁来防护rehash的前提下,这种per-chain locks在hashtable resize时有需要特别注意的地方。具体而言:rehash时,先依一定次序获取每个chain的lock,然后alloct新hashtable所需内存,然后将old hashtable的内容通过rehash算法填充至new hashtable。上面的操作成功完成后,将old table指针放入一个专门用于存放old table pointer的队列中,而非直接deallocate old table的存储空间(这样才能保证性能)。不过,为实现memory retiring,还有一些需要特别注意的地方(如lookup时需要添加不同于传统lookup的辅助逻辑,如hashtable pointer需要申明为volatile,等等),这里不再赘述,感兴趣的同学可以阅读原论文。
        这种技巧在Solaris的file descriptor locking实现代码中得到应用。
        11) Be aware of false sharing
        目前大多数CPU结构采用的cache coherency protocol是以write invalidate方式实现的,故在某些情况下,会引起False Sharing,这会降低系统性能(性能影响系数可能达到100x数量级),因此,编写并发或多线程程序时,要特别注意避免false sharing。
        关于false sharing的原理解释和避免方法,这里极力推荐两篇文章:MSDN - .NET Matters: False Sharing 及Lockfree Algorithms: False-sharing。
        12) Consider using nonblocking synchronization routines to monitor contention
        采用不会造成阻塞的同步方式,如可以用pthread_mutex_trylock()代替pthread_mutex_lock()以避免调用时直接阻塞(直接阻塞会让程序员失去"异常"处理的机会)。
        13) When reacquiring locks, consider using generation counts to detect state change
        考虑存在这种操作流程:某时刻释放lock1 => 获取lock2 => 释放lock2 => 重新获取lock1,显然,当重新获取lock1后,lock1保护的数据可能已经被改变,而验证这种变化通常是mission impossible。由此,可以考虑引入generation counts来辅助检测这种变化,即:要保护的数据结构中增加gen_count字段,每次数据结构更新时,同时更新gen_count的值;释放锁时,记录最新的gen_count;重新持有锁时,通过对本地记录的gen_count与数据结构中最新的gen_count做比较,就很容易判断期间数据是否被修改过。
        其实,很多软件通过本地版本号与Server端最新版本号对比来决定是否需要更新就是这种思路的直观应用。
        14) Use wait and lock-free structures only if you absolutely must
        在通常的应用中,与wait and lock-free带来的性能提升相比,正确实现lock-free机制所花费的代价要大得多,而且程序不易调试。因此,除非是在编写操作系统底层代码,一般的应用没有必要一味追求lock-free。
        15) Prepare for the thrill of victory - and the agony of defeat
        原文意思可概括为:编写正确高效且可扩展的并发程序是非常有挑战的,所以需要摆正心态且做好打持久战的准备。当系统始终达不到预期的并发或扩展效果时,要谨记一条原则:return to the system to gather data,即让数据说话,避免经验主义错误。

3. Conclusion
        尽管前面总结了15条Rules,但编写并发程序依然是公认的难点,甚至于有人将多核计算机的出现当作洪水猛兽。其实,这种担心大可不必:编写高质量的并发程序只是少数工程师(如操作系统或数据库开发者)的工作任务。对于大多数人来说,可以站在巨人的肩膀上,用已被广泛验证的高质量并发模块来组成自己的系统。^_^

【参考资料】
1. ACM Queue: Real-World Concurrency
2. Wikipedia: DTrace
3. Spinlocks and Read-Write Locks(自备梯子)
4. Wikipedia: Thundering herd problem
5. Wikipedia: Cache coherence
5. Wikipedia: False Sharing
6. MSDN: .NET Matters: False Sharing
7. Lockfree Algorithms: False-sharing

====================== EOF ======================


0 0
原创粉丝点击