线程同步的精要

  • 并发有两种基本的模型:

    • 一种是message passing(消息传递);
    • 另一种是shared memory(共享内存);
  • 在分布式系统中(有多台物理机需要通信), 运行在多台机器上的多个进程只有一种实用模型:message passing(消息传递), 因为多个物理机基本上不能共享内存;
  • 并发(concurrency);
  • 线程同步的四项原则, 按重要性排列:
    • 首要原则是尽量最低限度地共享对象, 减少需要同步的场合;

      • 一个对象能不暴露给别的线程就不要暴露;
      • 如果暴露就优先考虑immutable对象(const);
      • 实在不行才暴露可修改的对象,并用同步措施来充分保护它;n
    • 其次, 使用高级的并发编程构件, 如任务队列(TaskQueue), 生产者消费者队列(Producer-Consumer Queue), 闭锁(CountDownLatch)等;
    • 最后不得已才使用底层同步原语(primitives)时, 只用非递归的互斥器和条件变量, 慎用读写锁, 不要用信号量;
    • 除了使用atomic整数之外, 不要自己编写lock-free代码, 也不要用"内核级"同步原语; 不能凭空猜测'那种做法性能会更好', 比如spin lock(自旋锁) vs mutex(互斥量);

互斥量(mutex)

  • 主要是为了保护共享数据:

    • 用RAII(Resource Acquisition Is Initialization -- 资源申请即初始化)手法封装mutex的创建, 销毁, 加锁, 解锁这四个操作;

      • 保证锁的生效期间等于一个作用域(scope), 不会因异常而忘记解锁;
  • 只用非递归的mutex(即不可重入的mutex);
    • mutex分为递归(recursive)和非递归(non-recursive)两种, 这是POSIX的叫法, 另外的名字是可重入(reentrant)和非可重入;

      • 这两种mutex对线程间(inter-thread)的同步基本没有区别, 他们的唯一区别就是: 同一线程可以重复对recursive mutex加锁, 但是不能重复对non-recursive mutex加锁;
    • recursive mutex(可重入的互斥量)可能会隐藏代码里的一些问题:
      • 典型情况是, 以为拿到一把锁就可以修改对象了, 没想到外层代码已经拿到了锁, 正在修改(或读取)同一个对象呢;
  • 不手工调用lock()和unlock()函数, 一切交给栈上的Guard对象的构造和析构函数负责;
    • 这种做法叫做Scoped Locking(作用域加锁);
  • 在每次构造Guard对象时, 思考一路上(调用栈上)已经持有的锁, 防止因加锁顺序不同而导致死锁(deadlock);
    • 由于Guard对象是栈上对象, 看函数调用栈就能分析用锁的情况, 非常便利;
  • 不使用跨进程的mutex, 进程间通信只用TCP sockets;
    • 加锁和解锁在同一个线程, 线程a不能去unlock线程b已锁住的mutex(RAII自动保证);
    • 不要忘记解锁(RAII自动保证);
    • 不重复解锁(RAII自动保证);
    • 必要时可以考虑用PTHREAD_MUTEX_ERRORCHECK来排错;
  • pthread_atfork()函数讲解
  • GCC支持的无锁化编程
  • 利用thread apply all bt命令可以在gdb调试中查看所有的堆栈调用信息;

死锁(dead lock)

  • 坚持使用Scoped Locking(作用域加锁), 很容易在出现死锁的时候定位bug;

条件变量(condition variable)

  • 互斥器(mutex), 是加锁原语, 用来排他性地访问共享数据, 它不是等待原语;
  • 如果需要等待某个条件成立, 应该使用条件变量(condition variable);
    • 条件变量是一个或多线程等待某个布尔表达式为真, 即等待别的线程唤醒它;
    • 条件变量的学名叫管程(monitor);
  • 条件变量只有一种使用方式, 几乎不可能用错:
    • 对于wait端:

      • 必须与mutex一起使用, 该布尔表达式的读写虚受此mutex保护;
      • 在mutex已上锁的时候才能调用wait()函数;
      • 把判断布尔条件和wait()放到while循环中;
    • 对于signal/broadcast端:
      • 不一定要在mutex已上锁的情况下调用signal(理论上);
      • 在signal之前一般要修改布尔表达式;
      • 修改布尔表达式通常要用mutex保护(至少用作full memory barrier);
      • 注意区分signal和broadcast:
        • broadcast通常用于表明状态变化;
        • signal通常用于表示资源可用;
  • 条件变量是非常底层的同步原语, 很少直接使用一般都是用它来实现高层的同步措施, 如BlockingQueue或CountDownLatch(倒计时器);
    • 倒计时器(CoutDownLatch)是一种常用且易用的同步手段, 主要有两种用途:

      • 用于主线程等待多个子线程完成初始化;
      • 用于多个子线程等待主线程发起"起跑"命令;
  • 互斥量和条件变量构成了多线程编程的全部必备同步原语, 用它们即可完成任何线程同步任务, 二者不能相互代替;
    • 千万不要连mutex都没有学会、用好,一上来就考虑lock-free设计;

不要用读写锁和信号量

  • 读写锁(Readers-Writer lock, 简写为rwlock)是个看上去很美的抽象, 它明确区分了read和write两种行为;

    • 首选rwlock来保护共享状态, 这不见得是正确的;

      • 从正确性方面来说, 在持有read lock的时候修改了共享数据,在程序维护阶段容易犯的错误;
      • 从性能方面来说, 读写锁不一定比普通的mutex更高效, 读写锁要更新当前reader的数目, 如果临界区很小, 锁竞争不激烈, 那么mutex会更快;
      • reader lock允许提升为writer lock, 也可能不允许提升;
      • 通常reader lock是可重入的, writer lock是不可重入的, 但是为了防止writer lock饥饿, writer lock通常会阻塞后来的reader lock, 所以reader lock在重入的时候可能死锁;
      • 追求低延迟的读取场合也不适合读写锁;
      • 如果确实并发读写有极高的性能要求, 可以考虑read-copy-update;

封装Mutexlock、MutexLockGuard、Condition类

  • 这几个类都不允许拷贝构造和赋值;
  • 用mutexattr来显示指定mutex的类型;
  • 检查返回值尽量不用assert函数, 因为assert函数在release build里是空语句;
  • 需要non-debug的assert时, 或许google-glog的CHECK宏是一个不错的思路;
  • muduo库的特点是只提供最常用、最基本的功能, 特别有意避免提供多种功能近似的选择;
  • muduo库删繁就简, 举重若轻;
  • trylock函数的一个用途是用来观察lock contention;
  • 提供灵活性固然是本事, 然而在不需要灵活性的地方把代码写死, 更需要大智慧;
  • 一个多线程程序如果大量使用mutex和condition variable来同步, 基本跟用铅笔刀锯大树没啥区别;

线程安全的singleton实现

  • double checked locking(DCL)兼顾了效率与正确性;

    • 但有神牛指出由于乱序执行的影响, DCL是靠不住的;
    • C++ 实现要么次次锁, 要么eager initialize(饿的单例), 或者动用memory barrier这样的大杀器;
  • 在实践中, 用pthread_once就行(保证函数只执行一次);
  • 用pthread_once_t来保证lazy-initialization的线程安全, 线程安全由pthread库保证;

sleep不是同步原语

  • sleep, usleep, nanosleep只能出现在测试代码中, 比如写单元测试的时候;
  • 或者用于有意延长临界区, 加速复现死锁的情况;
  • sleep不具备memory barrier语义, 它不保证内存的可见性;
  • 生产代码中线程的等待可分为两种:
    • 一种是等待资源可用(要么等在select/poll/epoll_wait上, 要么等在条件变量上);
    • 一种是等着进入临界区(等在mutex上), 以便读写共享数据; 这种等待极短, 否则程序性能和伸缩性就会有问题;
  • 在程序的正常执行中, 如果需要等待一段已知的时间, 应该向event loop里注册一个timer, 然后在timer的回调函数里接着干活, 因为线程是个珍贵的共享资源, 不能轻易浪费(阻塞也是浪费);
  • 如果等待某个事件发生, 那么应该采用条件变量或IO事件回调, 不能用sleep来轮询;
  • 如果多线程的安全性和效率要靠代码主动调用sleep来保证, 这显然是设计出了问题;
  • 等待某个事件发生, 正确的做法是用select等价物或condition, 抑或(更理想的)高层同步工具;
  • 在用户态做轮询(polling)是低效的;

归纳与总结

  • 线程同步的四原则, 尽量用高层同步设施(线程池, 队列, 倒计时);
  • 使用普通互斥量和条件变量完成剩余的同步任务, 采用RAII惯用手法(idiom)和Scoped Locking(作用域加锁);
  • 用好这几样东西, 基本上能应付多线程服务端的各种场合;
  • 让正确的程序变快远比让一个快的程序变正确容易得多;
  • 有些高级语言通过framework来屏蔽多线程, 让多线程看起来像是写单线程程序(Java Servlet);
  • 掌握多线程编程, 才能更理智地选择用还是不用多线程, 因为你能预估多线程实现的难度与收益;
    • 把一个单线程程序改成多线程, 往往比从头实现一个多线程更困难;
    • 掌握同步原语和他们的使用场合是多线程编程的基本功;
  • Unix的signal在多线程下的行为比较复杂, 一般要靠底层的网络库(如Reactor)加以屏蔽, 避免干扰上层应用程序的开发;
  • 不能听信传言或是凭感觉优化;
  • 真正影响锁性能的不是锁, 而是锁争用(lock condition);
  • 在分布式系统中, 多机伸缩性(scale out)比单机性能优化更值得投入精力;

借shared_ptr实现copy-on-write

  • 用shared_ptr来管理共享数据;

    • shared_ptr 是引用计数型智能指针, 如果当前只有一个观察者, 那么引用计数的值为1;
    • 对于write端, 如果发现引用计数为1, 这时可以安全的修改共享对象, 不必担心有人正在读它;
    • 对于read端, 在读之前把引用技术加1, 读完之后减1, 这样保证在读的期间其引用计数大于1, 可以阻止并发写;
    • 比较难的是write端, 如果发现引用计数大于1, 该如何处理, sleep一小段时间肯定是错的;
      • 一般不能在原来上面修改, 得创建一个副本, 在副本上修改, 修改完了再替换;
      • 如果没有用户在读, 那么就直接修改, 节约一次拷贝;
05-08 08:15