《Linux多线程服务端编程》读摘

  花了几天的时间,把陈硕老师的《Linux多线程服务端编程》给看完了。
  其实就是当初冲着网上的评价很高,号称为国人难得的C++开发之佳作,这本书很早之前就已经买了。当时一开卷就是C++中各种构造析构安全,复杂隐晦的多线程间的竞争条件,尤其当时对C++忘光了,而Boost、C++11、异步原理又不太熟悉,再加上工作上没有相关的任务做驱动,所以被唬住后也就将其搁在一边了。刚好最近在做C++的服务端开发,虽然用的是Boost.asio现成的异步框架,但是拿来看看,收获还是不少的。这本书总体内容显得还是比较“杂”的,包括了作者muduo异步库的设计思路、使用方法、实现过程和细节,还有其它跟muduo无关的,比如一些工程实践和开发经验,对C/C++语言的原理的解析,C++面向对象设计方式的评判等,相比起来显得更加的宝贵。
  书中关键点都用红笔标记下了,为了后面温习查阅方便,本篇用以记录相关摘要。可能略显零碎,但也会是字字珠玑。

第一部分 多线程编程系统

1.1 对象构造线程安全

  在对象构造期间不要泄露this指针,也不要在构造函数中注册回调函数,不要把this传递给跨进程对象,主要是确保在对象构造完成之前没有别的途径可以调用产生不确定行为。通常采用的是构造函数+initialize()的两段式构造,将复杂的工作放到构造完对象之后再初始化一次,这样缩短构造函数的时间,同时也不必在构造函数中做复杂的异常处理。

1.2 析构对象线程安全

  对象的成员mutex可以保护对象的运行,但是析构却不行,因为mutex的生命周期最多和类的生命周期一样长,根本无法保护。根本方法还是智能指针,确保对象最后引用结束的时候,对象资源被自动释放掉。

1.3 智能指针

  shared_ptr对象的引用计数本身是安全且无锁的,但是智能指针对象本身的读写(包括析构操作)却不是,如果要多个线程同时读写同一个shared_ptr对象,需要加锁。
  这里的技巧是:可以在临界加锁区中将全局的shared_ptr复制成一个局部变量,后面操作局部变量,减少临界区;函数传参采用reference to const的方式传递,减少拷贝操作,也能提高性能;析构的时候不要在临界区reset(),而是在临界区中用局部变量swap,然后在临界区外部进行对象析构,减少临界区的范围。
  对于析构任务重的情况,可以再开一个单独的线程,通过BlockingQueue>把析构任务都提交到那个线程,减少对关键线程的影响。
  shared_ptr还需要注意避免循环引用,对于对象周期和程序生命周期一样长的无所谓,否则交叉share_ptr在析构时候会发生问题。这时候应该owner持有指向child的shared_ptr,而child拥有指向owner的weak_ptr。

1.4 线程同步的原则

  a. 尽量最低限度的共享对象,尽量共享immutable对象,减少需要同步的情况;一旦产生了写,其他所有的读操作也都变成不是线程安全的了。
  b. 使用高级的并发编程构建,比如TaskQueue、Producer-CustomerQueue、CountDownLatch等;
  c. 只使用非递归的互斥器和条件锁,不用读写锁;
  d. 使用atomic;
  e. C的很多库函数都不是线程安全的,因为用到了静态空间,需要使用_r的版本,C++的容器是不安全的,C++的算法库大多是安全的,因为他们大多是没有状态的虚函数。
  f. 多个锁操作很容易导致死锁,有些时候可以考虑比较锁地址,让地址小的先锁,以保证锁的顺序。

1.5 互斥器Mutex

  使用非递归的mutex,不手动lock()/free(),采用RAII机制的Guard对象自动加锁和解锁;不要使用跨进程的mutex,跨进程通信采用sockets;加锁和解锁必须在同一个线程中。

1.6 条件变量和CountDownLatch

1
2
3
4
5
6
7
MutexLockGuard lock(mutex);
while(queue.empty()) { //防止spurious wakeup
cond.wait();}

{ MutexLockGuard lock(mutex);
queue.push_back(x);}
cond.notify();

CountDownLatch比如可以用于主线程等待多个子线程初始化完毕

1
2
3
4
5
6
7
MutexLockGuard lock(mutex);
while(count_ > 0) { cond.wait();}

{ MutexLockGuard lock(mutex);
count_ --;
if( count_ == 0) cond.notifyAll();
}

1.7 多进程和fork

  只有单线程的程序才可以fork(),如果多线程执行了fork(),那么只会fork()当前运行的线程,其它线程都会消失掉。一般可以在程序的开始,先fork出一个看门狗进程,看门狗进程需要是单进程的。
  多进程的程序如果大量共享数据,就需要大规模的共享内存,而且进程间的通信同步都比较麻烦,而且一旦一个进程在临界区内阻塞或者crash,其他进程也会被锁死,这种情况还不如使用多线程,多进程并不会增加多少程序的稳定性。

1.8 将IO操作剥离给单独的线程

  无论SSD和RAID怎么样,还是在传统的思路上考虑文件IO和数据库操作会比较慢,比如日志的写或者简单逻辑的数据库的更新,可以考虑到放到一个BlockingQueue队列上,那么请求线程就可以立即返回,而这些工作由单独的后台线程去处理(不过需要考虑后台线程的处理能力,否则BlockingQueue会越来越大)。

1.9 __thread线程局部变量

  thread关键字是GCC支持的局部存储措施,其可以修饰全局变量或者函数中的静态变量(对于类类型只支持POD类),其在初始化只能使用编译期间的常量值。thread变量是针对每个线程一份独立实体,各个线程的变量值互不干扰。
  C++11标准中引入了新的关键字thread_local,而Boost的thread库提供了thread_specific_ptr,采用类似智能指针的方式实现了线程本地存储机制。

1.10 多线程与signal

  signal本身就比较复杂,在信号处理函数中只能调用异步信号安全的函数,即可重入函数;同时如果修改全局变量也是比较危险的,因为编译器可能将其认为不会改变而优化掉信号处理的修改。
  不要在多线程中使用信号,不用SIGUSR1触发服务端行为,采用增加监听端口的方式进行通信;不要使用基于信号的定时函数,其是不可靠的;不主动处理信号的行为,使用其默认语义,除了PIPE信号;如果要处理,使用signalfd的方式,把信号转化成文件描述的方式,杜绝直接使用signal handler。
  现在看来内核很多操作都fd化,比如signalfd,timerfd,eventfd等,好处就是可以用相同的接口,同时也更容易整合到各种异步框架下面去。

第二部分 moduo网络库

2.1 TCP连接不主动关闭

  TCP本身是一个全双工协议,同一个描述符可以读也可以写。通常在服务端,需要的时候对socket关闭写方向的连接,保留读方向的连接,称为TCP half-close,这样的好处是如果关闭的同时对端还在传输数据过来,就不会漏收这些数据。同时习惯上,对端程序在read()返回0之后,会主动关闭自己的写端,此时服务端的读端就会被被动关闭了。当然对于恶意不关闭的,通过time_wheel回收不活跃的连接,也不会有太大的问题。

2.2 TCP分包

  对于短链接的TCP服务,一般发送方会主动关闭连接,表示一条消息传送完毕了,自然就意味着消息的结尾,就没有分包的问题。对于长连接服务,分包的形式有:
  a. 约定固定长度的消息;
  b. 使用特殊字符或者字符串作为消息的边界,比如HTTP中的\r\n;
  c. 每条消息的头部约定一个字段,表示消息体的长度(hton_32);
  d. 利用消息本身来进行分包,比如json的{}配对。

2.3 限制并发连接数目

  默认情况下Linux一个进程最大打开的文件数目是1024,受到/etc/security/limits.conf中的nofile设置的限制,如果需要突破这个限制可以修改这个文件。
  当然如果程序达到这个限制,行为会变的很被动,一般都是自己设置一个soft limit,一旦接受的到的连接数到达这个限制,就在accept后立即关闭新拿到的socket连接,让程序进入拒绝服务状态。

2.4 time wheel踢掉空闲连接

  对于一个连接如果若干秒没有数据,就被认为是空闲连接,应该被处理掉。书中用到的circlur_buffer无锁结构,将时间分片轮寻,书中的方式每次收到数据都会向队列尾部添加活动链接的弱引用,然后事件切换的时候检查头部的连接。当然这样做是没什么问题的,但是操作的代码比较的高。
  我用的方式比较粗,每次建立新连接的时候,会把连接的weak_ptr保存在当前的时间片上,同时每个连接带有一个touch_time事件戳,当有数据交换时候更新这个时间戳。后面在时间片切换的时候,检查每个连接是否存在(提升为shared_ptr),如果存在检查时间戳是否超时,然后决定是否删除连接。因为不每次添加和修改连接到时间片,所以处理速度比较快,但是缺陷是超时的时间不一定,根据socket的行为为[n, 2n-1]。

2.5 小杂项

  SIGPIPE信号处理是需要忽略的,因为这个信号的默认行为是终止进程,但是网络中对方断开连接而本地继续写入的话,会触发这个信号,所以必须要忽略掉。
  TCP No Delay和TCP keepalive是两个比较重要的TCP选项,前者是禁用Nagle算法,避免连续发报出现延时,对编写低延时以及特殊的程序比较重要(当时开发socket代理的时候不知道这个选项,然后被折磨惨了,估计我会终生记住这个家伙);TCP keepalive是定期探测TCP连接是否还存在,保持一个心跳的作用。

2.6 处理自连接

  一般程序如果先bind,就会占用端口和套接字,是没有问题的。但是当程序没有显式bind的时候,如果连接的客户端和服务端都在同一个IP主机上,而且服务端端口在local_port_range的范围内,连接的过程中客户端端口有很小几率的可能和服务端端口是一致的,此时用netstat -an会发现源端口和目的端口都是一样的,对于TCP三次握手是符合协议的,但是这样的连接无法正常通信,需要重启。

第三部分 工程实践部分

3.1 编译选项

  C++的机制比较的复杂,所以推荐代码中使用严格的编译选项,排除可能的隐式规则和实际期望行为之间的差异。

1
-Wall -Wextra -Werror -Wconversion -Wno-unused-parameter -Wold-style-cast -Woverloaded-virtual -Wpointer-arith -Wshadow -Wwrite-strings -march=native

而对于有些代码中如果需要临时忽略一些错误,可以采用下面语句:

1
2
3
4
5
6
7
#if defined(__GNUC__)
#pragma GCC diagnostic warning "-Wunused-function"
#endif
...
#if defined(__GNUC__)
#pragma GCC diagnostic error "-Wunused-function"
#endif

3.2 前向声明

  前向声明可以减少头文件的包含,自然也降低了编译期间的循环依赖,加快编译速度。
  对于class Foo,以下的几种使用不需要看到其完整的定义:
  a. 定义或者声明Foo *和Foo &,包括用于函数参数、返回类型、局部变量、类成员变量等。这是因为C++的内存模型是flat的,Foo的定义无法改变Foo的指针和引用的含义;
  b. 声明一个以Foo为参数或者返回类型的函数,如果代码里面调用了这个函数,就需要提供这个类型的完整定义了,因为编译器需要使用Foo的拷贝构造函数和析构函数,需要看到类的完整声明。

3.3 模版实例化膨胀

  虽然传统上说模板的定义需要放到头文件中,其实实际上是发生链接错误。工程上,可以把模板的实现放到库或者源代码中,二头文件中只放声明,这就是事先进行显式实例化。
  还有对于private类型的成员模板,其实现也不需要放到头文件中,可以只在代码文件中实现,因为private类型只有类实现的本身会用到它。

参考