第3章 多线程服务器的适用场合与常用编程模 型

发布时间 2023-04-07 22:45:32作者: 好人~

3.1 进程与线程

每个进程有自己独立的地址空间(address space)

线程的特点是共享地址空间,从而可以高效地共享数据

3.2 单线程服务器的常用编程模型

两种高效的事件处理模式:Reactor和Proactor。同步I/O模型通常用于实现Reactor模式,异步I/O模型则用于实现Proactor模式。不过,也可以使用同步I/O方式模拟出Proactor模式。

  • Reactor模式:要求主线程(I/O处理单元)只负责监听文件描述上是否有事件发生,主线程不负责对文件描述符上数据的读写,有的话就立即将该事件通知工作线程(逻辑单元)。如epoll_wait——注册事件,事件发生后通知主线程,主线程将已经发生的事件放入请求队列,并唤醒请求队列上的某个线程去处理这个事件。对于IO密集的应用是个不错的选择。
  • Proactor模式:与Reactor模式不同,Proactor模式将所有I/O操作都交给主线程和内核来处理,工作线程仅仅负责业务逻辑。以aio_read和aio_write为例——注册事件并告诉内核读(写)缓冲区的位置,当socket上的数据被读入用户缓冲区后 或 当用户缓冲区的数据被写入socket之后,向应用程序发送一个信号,以通知应用程序数据处理。Proactor模式就是使用异步IO,将数据的读/写操作都放在内核中运行,当读/写完毕以后,通知工作线程走后续处理(工作线程不做读/写操作)。
  • 同步I/O方式模拟出Proactor模式:主线程执行数据读写操作,读写完成之后,主线程向工作线程通知这一“完成事件”。

muduo库使用的是Reactor模式。在Reactor模式中,程序的基本结构是一个事件循环(event loop),以事件驱动(event-driven)和事件回调的方式实现业务逻辑。
缺点:基于事件驱动的编程模型也有其本质的缺点,它要求事件回调函数必须是非阻塞的。对于涉及网络IO的请求响应式协议,它容易割裂业务逻辑,使其散布于多个回调函数之中,相对不容易理解和维护。现代的语言有一些应对方法(例如coroutine)。

回调函数含义:回调函数 C++

3.3 多线程服务器的常用编程模型

多线程服务器的常用编程模型,大概有这么几种

  • 1.每个请求创建一个线程,使用阻塞式IO操作。
  • 2.使用线程池,同样使用阻塞式IO操作。与第1种相比,这是提高性能的措施。
  • 3.使用non-blocking IO+IO multiplexing。
  • 4.Leader/Follower等高级模式。

3.3.1 one loop per thread

此种模型下,程序里的每个IO线程有一个event loop(或者叫 Reactor),用于处理读写和定时事件(无论周期性的还是单次的)。也就是说一个线程可以将定时任务或者tcp请求注册到其他线程中,并且每个线程可能用于处理多个定时任务或者tcp请求,线程中监听定时任务或者tcp请求(可能多个定时任务和tcp请求)并进行相应的处理。也就是说线程不止可用于处理一个任务,而且可以处理多个任务。这种方式的好处是:

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

要允许一个线程往别的线程的loop里塞东西,这个loop必须得是线程安全的。

3.3.2 线程池

使用BlockingQueue作为线程的任务队列,如BlockingQueue<T>

3.3.3 推荐模式

总结起来,我推荐的C++多线程服务端编程模式为:one (event) loop per thread+ thread pool。

  • event loop(也叫IO loop)用作IO multiplexing,配合non-blocking IO和定时器。
  • thread pool用来做计算,具体可以是任务队列或生产者消费者队列。

3.4 进程间通信只用TCP

Linux下进程间通信(IPC)的方式数不胜数,光[UNPv2]列出的就 有:匿名管道(pipe)、具名管道(FIFO)、POSIX消息队列、共享内 存、信号(signals)等等,更不必说Sockets了。同步原语 (synchronization primitives)也很多,如互斥器(mutex)、条件变量 (condition variable)、读写锁(reader-writer lock)、文件锁(record locking)、信号量(semaphore)等等。

进程间通信我首选Sockets(主要指TCP,我没有用过UDP,也不考 虑Unix domain协议),其最大的好处为:

  • 可以跨主机,具有伸缩性:把进程分散到同一局域网的多台机器上,程序改改host:port配置就能继续用。

  • 端口和文件描述符资源由系统自动回收:TCP port由一个进程独占,且操作系统会自动回收(listening port和 已建立连接的TCP socket都是文件描述符,在进程结束时操作系统会关 闭所有文件描述符)。这说明,即使程序意外退出,也不会给系统留 下垃圾,程序重启之后能比较容易地恢复,而不需要重启操作系统 (用跨进程的mutex就有这个风险)。
    防止进程重复启动:还有一个好处,port是由各个进程独占 的,那么可以防止程序重复启动,重复启动的进程抢不到port,自然就没 法初始化了,避免造成意料之外的结果。

  • 两个进程通过TCP通信,如果一个崩溃了,操作系统会关闭连接,另一个进程几乎立刻就能感知,可以快速进行错误处理。

  • 可使用tcpdump和Wireshark分析bug和性能,也可以用tcpcopy14之类的工具进行压力测试。

  • TCP还能跨语言,服务端和客户端不必使用同一种语言。

  • 进程可单独重启:如果网络库带“连接重试”功能的话,我们可以不要求系统里的进程以特定的顺序启动,任何一个进程都能单独重启。换句话说,TCP连接是可再生的,连接的任何一方都可以退出再启动,重建连接之后就能继续工作,这对开发牢靠的分布式系统意义重大。

进程间使用TCP进行通信,推荐使用Google Protocol Buffers作为消息格式。

TCP sockets和pipe:在编程上,TCP sockets和pipe都是操作文件描述符,用来收发字节 流,都可以read/write/fcntl/select/poll等。不同的是:

  • TCP是双向的, Linux的pipe是单向的,进程间双向通信还得开两个文件描述符,不方 便11;
  • 而且进程要有父子关系才能用pipe,这些都限制了pipe的使用。

在收发字节流这一通信模型下,没有比Sockets/TCP更自然的IPC了。当 然,pipe也有一个经典应用场景,那就是写Reactor/event loop时用来异 步唤醒select(或等价的poll/epoll_wait)调用。

TCP与共享内存:
作者说本地机器上的进程之间的TCP通信的吞吐量并不低(见§6.5.1的测试结果),所以在本地机器上也没有必要使用共享内存。

分布式系统中使用TCP长连接通信