1 线程同步的基本原则

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

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

    1.1 Mutex使用原则

  • 用RAII手法封装mutex的创建、销毁、加锁、解锁这四个操作。
  • 只用非递归的mutex,递归mutex是为了防止线程自己锁死自己。如果一个函数既可能在已加锁的情况下调用,又可能在未加锁的情况下调用,那么就拆成两个函数:
    • 跟原来的函数同名,函数加锁,转而调用第2个函数。
    • 给函数名加上后缀WithLockHold,不加锁,把原来的函数体搬过来。
  • 不手工调用lock()和unlock()函数,一切交给栈上的Guard对象的构造和析构函数负责
  • 在每次构造Guard对象的时候,思考一路上(调用栈上)已经持有的锁,防止因加锁顺序不同而导致死锁
  • 不使用跨进程的mutex,进程间通信只用TCP sockets。
  • 加锁、解锁在同一个线程,线程a不能去unlock线程b已经锁住的mutex(RAII自动保证)。
  • 别忘了解锁(RAII自动保证)。
  • 不重复解锁(RAII自动保证)。
  • 必要的时候可以考虑用PTHREAD_MUTEX_ERRORCHECK来排错。

    1.2 不使用读写锁和信号量

    使用读写锁可能存在的错误:

  • 持有reader lock的时候修改了共享数据。这通常发生在程序的维护阶段,为了新增功能,不小心在原来read lock保护的函数中调用了会修改状态的函数

  • 性能方面来说,读写锁不见得比普通mutex更高效,reader lock需要更新当前reader的数目
  • reader lock可能允许提升为writer lock
  • 为了防止writer饥饿,writer lock通常会阻塞后来的reader lock,因此reader lock在重入的时候可能死锁

信号量不是必备的同步原语,因为条件变量配合互斥器可以完全替代其功能,而且更不易用错
如果程序里需要解决如“哲学家就餐”之类的复杂IPC问题,我认为应该首先检讨这个设计:为什么线程之间会有如此复杂的资源争抢(一个线程要同时抢到两个资源,一个资源可以被两个线程争夺)?

1.3 sleep不能用于同步

sleep()/usleep()/nanosleep()只能出现在测试代码中,比如写单元测试的时候;或者用于有意延长临界区,加速复现死锁的情况。sleep不具备memory barrier语义,它不能保证内存的可见性。
在程序的正常执行中

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

如果多线程的安全性和效率要靠代码主动调用sleep来保证,这显然是设计出了问题。

2 多线程的常用编程模型

2.1 one loop per thread

此种模型下,程序里的每个IO线程有一个event loop(或者叫Reactor IO复用函数),用于处理读写和定时事件(无论周期性的还是单次的)。
这种方式的好处是:

  • 线程数目基本固定,可以在程序启动的时候设置,不会频繁创建与销毁。
  • 可以很方便地在线程间调配负载。
  • IO事件发生的线程是固定的,同一个TCP连接不必考虑事件并发。

    2.2 thread pool

    基于线程池的任务队列是多线程编程的利器,它的实现可参照Java.util.concurrent里的(Array|Linked)BlockingQueue(这份Java代码可读性很高,1个mutex,2个condition variables),健壮性要高得多。
    如果不想自己实现,用现成的库更好。muduo里有一个基本的实现,包括无界的BlockingQueue和有界的BoundedBlockingQueue两个class。有兴趣的读者还可以试试Intel Threading Building Blocks里的concurrent_queue,性能估计会更好。

    2.3 推荐的组合

    总结起来,推荐的C++多线程服务端编程模式为:one (event)loop per thread + thread pool。一个多线程的程序具体的可以分为三类:

  • event loop(也叫IO loop)用作IO multiplexing,配合non-blocking IO和定时器。

  • thread pool用来做计算,具体可以是任务队列或生产者消费者队列。要避免任何阻塞操作
  • 第三方库所用的线程,比如logging,又比如databaseconnection

3 进程间通信只用TCP

进程间通信我首选Sockets(主要指TCP,我没有用过UDP,也不考虑Unix domain协议),其最大的好处在于:可以跨主机,具有伸缩性(其他IPC方法都不可以)。反正都是多进程了,如果一台机器的处理能力不够,很自然地就能用多台机器来处理。把进程分散到同一局域网的多台机器上,程序改改host:port配置就能继续用。
具体好处如下:

  • 可以跨主机,具有伸缩性
  • TCP port由一个进程独占,且操作系统会自动回收(listening port和已建立连接的TCP socket都是文件描述符,在进程结束时操作系统会关闭所有文件描述符)
  • 两个进程通过TCP通信,如果一个崩溃了,操作系统会关闭连接,另一个进程几乎立刻就能感知,可以快速failover
  • 便于调试,分析(tcpdump和Wireshark等工具)
  • TCP还能跨语言,服务端和客户端不必使用同一种语言

使用TCP字节流传输数据,需要定义好的消息格式,可以参考Protocol BuffergRPC

4 其他注意

  1. pthread_t并不适合用作程序中对线程的标识符。建议使用gettid()系统调用的返回值作为线程id
  2. 不应该从外部杀死线程
  3. exit()函数在C++中的作用除了终止进程,还会析构全局对象和已经构造完的函数静态对象,可能因为析构导致其他正在使用对象的线程死锁。可以考虑_exit(),它不会进行任何清理工作。
  4. __thread关键字修饰基本类型变量。__thread变量是每个线程有一份独立实体,各个线程的变量值互不干扰。除了这个主要用途,它还可以修饰那些“值可能会变,带有全局性,但是又不值得用全局锁保护”的变量。
  5. 多线程程序应该遵循的原则是:每个文件描述符只由一个线程操作,从而轻松解决消息收发的顺序性问题,也避免了关闭文件描述符的各种race condition。一个线程可以操作多个文件描述符,但一个线程不能操作别的线程拥有的文件描述符
  6. fork()之后,子进程会继承地址空间和文件描述符,不会继承如下内容:
    • 父进程的内存锁,mlock(2)、mlockall(2)
    • 父进程的文件锁,fcntl(2)
    • 父进程的某些定时器,setitimer(2)、alarm(2)、timer_create(2)等
  7. Linux的fork()只克隆当前线程的thread of control,不克隆其他线程。fork()之后,除了当前线程之外,其他线程都消失了。也就是说不能一下子fork()出一个和父进程一样的多线程子进程。这就造成一个危险的局面。其他线程可能正好位于临界区之内,持有了某个锁,而它突然死亡,再也没有机会去解锁了。
  8. 在多线程程序中,使用signal的第一原则是不要使用signal:
    • 不要用signal作为IPC的手段,包括不要用SIGUSR1等信号来触发服务端的行为。
    • 也不要使用基于signal实现的定时函数,包括alarm/ualarm/setitimer/timer_create、sleep/usleep等等。
    • 不主动处理各种异常信号(SIGTERM、SIGINT等等),只用默认语义:结束进程。有一个例外:SIGPIPE,服务器程序通常的做法是忽略此信号,否则如果对方断开连接,而本机继续write的话,会
      导致程序意外终止。
    • 在没有别的替代方法的情况下(比方说需要处理SIGCHLD信号),把异步信号转换为同步的文件描述符事件。传统的做法是在signal handler里往一个特定的pipe(2)写一个字节,在主程序中从这个pipe读取,从而纳入统一的IO事件处理框架中去。现代Linux的做法是采用signalfd()把信号直接转换为文件描述符事件,从而从根本上避免使用signal handler

      5 多线程设计的终极原则

  • 线程是宝贵的,一个程序可以使用几个或十几个线程。一台机器上不应该同时运行几百个、几千个用户线程,这会大大增加内核scheduler的负担,降低整体性能。
  • 线程的创建和销毁是有代价的,一个程序最好在一开始创建所需的线程,并一直反复使用。不要在运行期间反复创建、销毁线程,如果必须这么做,其频度最好能降到1分钟1次(或更低)。
  • 每个线程应该有明确的职责,例如IO线程(运行EventLoop::loop(),处理IO事件)、计算线程(位于ThreadPool中,负责计算)等等。
  • 线程之间的交互应该尽量简单,理想情况下,线程之间只用消息传递(例如BlockingQueue)方式交互。如果必须用锁,那么最好避免一个线程同时持有两把或更多的锁,这样可彻底防止死锁。
  • 要预先考虑清楚一个mutable shared对象将会暴露给哪些线程,每个线程是读还是写,读写有无可能并发进行。