Linux多线程服务端编程

1 当一个对象能被多个线程同时看到时,那么这个对象的销毁时机就会变得模糊不清,可能出现多种竞态条件:

  • 在即将析构一个对象时,从何而知此刻是否有其他线程正在执行该对象的成员函数
  • 如何保证在执行成员函数期间,对象不会在另一个线程被析构
  • 在调用某个对象的成员函数之前,如何得知这个对象还活着?它的析构函数会不会碰巧执行到一半?

解决这些race condtion是C++多线程编程面临的基本问题。可以使用shared_ptr来一劳永逸地解决这些问题。

2 对象构造要做到线程安全,唯一的要求是构造期间不能泄漏this指针。

基于此,二段式构造–即构造函数 + initialize() – 有时会是好办法。这种方式虽然不符合C++教条,但是多线程下别无选择。

3 mutex不能安全地保护析构,因为一旦执行析构,mutex对象也会被销毁。这样,在多线程的情况下,如果其他线程正在使用mutex用到一半,就会有问题。

4 空悬指针:

两个指针p1,p2,指向堆里的同一个对象Object,并且p1和p2位于不同的线程中(线程A和线程B)。假设线程A通过p1指针将对象销毁了,那么p2就成了悬空指针。这是一种典型的C/C++内存错误。

要想安全地销毁对象,最好在别人(即别的线程)都看不到的情况下,偷偷地做。这个也正是垃圾回收(gc)的原理,所有人用不到的一定是垃圾。

(悬空指针,是指指向的内容已经被释放的指针)

5 C++里可能出现的内存问题大致有如下几个方面:

  • 缓冲区溢出 (buffer overrun)
  • 空悬指针 / 野指针
  • 重复释放 (double delete)
  • 内存泄漏 (memory leak)
  • 不配对的 new[] / delete
  • 内存碎片 (memory fragment)

而正确地使用智能指针可以很轻易地解决前5种问题

6 shared_ptr的拷贝开销要比原始指针的拷贝开销要高(因为拷贝的时候需要修改引用计数,而修改引用计数需要加锁操作)。所以,我们在将shared_ptr作为函数参数传递的时候,尽量使用常引用的形式,这样减少拷贝次数,来减少性能损失。

7 让this指针,能变身为shared_ptr的方法,是让类继承 enable_shared_from_this。

8 弱回调:如果对象还活着,就调用它的成员函数,否则忽略之。

9 read-copy-update

10 不推荐使用信用量(Semaphore),原因:

  • 条件变量配合互斥量可以完全替代其功能,而且更不易出错
  • semaphore has no notion of ownership
  • 信号量有自己的计数值,而通常我们自己的数据结构也有长度值,这就造成了同样的信息存放了两份,需要时刻保持一致,这增加了程序员的负担和出错的可能。
  • 如果要控制并发度,可以考虑用muduo::ThreadPool

11 使用 pthread_once 来实现 Singleton

12 在”non-blocing IO + IO multiplexing” 这种模型(即Reactor模式)中,程序的基本结构是一个事件循环(event loop), 以事件驱动(event-driven) 和事件回调的方式实现业务逻辑。

Reactor摸型的优点:

编程不难,效率也不错。不仅可以用于读写socket,连接的建立,甚至DNS解析都可以用非阻塞的方式进行,以提高并发度和吞吐量,对于IO密集的应用是一个不错的选择。

缺点:

它要求事件回调函数必须是非阻塞的。对于涉及网络IO的请求响应式协议,它容易割裂业务逻辑,使其散布于多个回调函数之中,相对不容易理解和维护。

13 one loop per thread

libev的作者说:

One loop per thread is usually a good model. Doing this is almost never wrong, sometimes a better-performance model exists, but it is always a good start.

这种方式的好处,在于:

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

Eventloop代表了线程的主循环,需要让哪个线程干活,就把timer或IOchannel(如TCP连接)注册到哪个线程的loop里即可。

对实时性有要求的额connection 可以单独用一个线程;

数据量大的connection可以独占一个线程,并把数据处理任务分摊到另几个计算线程中(用线程池);

其他次要的辅助性connection可以共享一个线程。

对于具有一定规模的服务端程序,一般就会采用 non-blockong + IO multiplexing, 每个connection/acceptor 都会注册到某个eventloop上,程序里有多个event loop,每个线程至多有一个event loop。

多线程程序对event loop 提出了更高的要求,那就是“线程安全”。要允许一个线程往别的线程的loop里塞数据,这个loop必须得是线程安全的。

14 进程间通信首选Sockets(主要是指TCP),其最大的好处在于:可以跨主机,具有伸缩性。其他优势:

  • 在编程上,TCP sockets和pipe都是操作文件描述符,用来收发字节流,都可以 read/write/fcntl/poll等。不同的是,TCP是双向的;Linux的pipe是单向的,使用没有TCP方便
  • TCP的port是由一个进程独占的,而且操作系会自动回收(listening port 和已建立连接的TCP socket都是文件描述符,在进程结束时操作系统会自动关闭所有文件描述符)。这说明,即使程序意外退出,也不会给系统留下垃圾,程序重启之后就可以比较容易地恢复,而不需要重启操作系统。还有一个好处,既然port是独占的,就可以防止程序重复启动。
  • 两个进程通过TCP通信,如果一个崩溃了,操作系统会关闭连接,另一个进程就会立刻感知到,可以快速failover(故障转移)。
  • 与其他IPC相比,TCP的一个天生的好处是“可记录、可重现”。tcpdump和Wireshark 是解决两个进程间协议和状态争端的好帮手,也是性能(吞吐量、延迟等)分析的利器。我们可以借此编写分布式程序的自动化回归测试。还可以用tcpcopy之类的工具进行压力测试。
  • TCP还能跨语言,服务端和客户端之间可以不必使用同一种语言
  • 使用TCP这种字节流方式通信,会有 marshal/unmarshal的开销,这就要求我们选用合适的消息格式,准确的说是 wire format(字节序列?),推荐用 Google Protocol Buffers

15 使用TCP长连接的好处有两点:

  • 容易定位分布式系统中的服务之间的依赖关系。只要在机器上运行 netstat -tpna | grep :port 就能立刻列出用到某服务的客户端地址,然后在客户端的机器上用 netstat 或者 lsof 命令找出是哪个进程发起的连接。
  • 通过接收和发送队列的长度也比较容易定位网络或者程序故障。

16 本书对 “服务器开发” 的定义,用一句话形容:

跑在多核机器上的Linux用户态的没有用户界面的长期运行的 网络应用程序,通常是分布式系统的组成部件。

17 多线程的适用场景时:提高响应速度,让IO和“计算”相互重叠,降低latency(延迟)。虽然多线程不能提高绝对性能,但是能提高平均响应性能。

一个程序要做成多线程的,大致要满足:

  • 有多个CPU可用。单核机器上多线程没有性能优势(但或许能简化并发业务逻辑的实现)
  • 线程间有共享数据,即内存中的全局状态。
  • 共享的数据是可以修改的,而不是静态常量表。
  • 提供非均质的服务。即,事件的响应有优先级差异,我们可以用专门的线程来处理优先级高的事件。防止优先级反转。
  • latency 和 throuthput 同样重要,不是逻辑简单的 IO密集或是 CPU密集。换言之,程序是有相当的计算量的
  • 能scale up(有规模的增长)。 一个好的多线程程序应该能享受增加CPU数目带来的好处,一旦CPU从8核升级到16核,程序能体现出这种升级带来的性能提升
  • 具有可预测的性能。随着负载增加,性能缓慢下降,超过某个临界点之后会极速下降。线程数目一般不随负载变化。
  • 多线程能有效地划分责任与功能,让每个线程的逻辑比较简单,任务单一,便于编码。而不是所有逻辑塞到一个event loop中,导致不同类别的事件之间相互影响

18 多线程服务程序中的线程大致可以分为三类:

  • IO线程,这类线程的主循环是IO multiplexing,阻塞地等在 select/poll/epoll_wait 等系统调用上。这类线程也处理定时事件。当然,它的功能不光光是IO,有些简单的计算也可以放入其中,比如消息的编码或者解码等。
  • 计算线程,这类线程的主循环是 blocingqueue, 阻塞地等在 conditionvariable上。这类线程一般位于 thread pool中。这种线程一般不涉及IO,一般要避免任何阻塞操作
  • 第三方库所用的线程,比如 logging ,又比如database connection等

19 Linux 能同时启动多少个线程?

对于32-bit Linux,一个进程的地址空间是4GB,其中用户态能访问的为3GB左右,而一个线程的默认栈大小是10 MB,简单计算,一个进程大约可以同时启动300个线程。

对于64-bit系统,线程数目可大大增加

20 尽管C++03标准没有明说标准库的线程安全性,但

  • 我们可以遵循一个基本原则:凡是非共享的对象都是彼此独立的,如果一个对象从始至终只被一个线程使用,那么它就是安全的。
  • 另外一个事实标准是:共享的对象的read-only操作是安全的,前提是不能有并发的写操作。