muduo库学习之常用编程模型04——常用编程模型与进程间通信方式的选择

东阳的学习笔记

原文链接:https://blog.csdn.net/yuyin86/article/details/7086424

1. 单线程服务器常用地编程模型

常用的单线程编程模型见https://blog.csdn.net/qq_22473333/article/details/112910686

在高性能的网络程序中,使用得最为广泛的恐怕要数“non-blocking IO + IO multiplexing”这种模型,即 Reactor 模式,我知道的有:

l lighttpd,单线程服务器。(nginx 估计与之类似,待查)

  • libevent/libev

  • ACE,Poco C++ libraries(QT 待查)

  • Java NIO (Selector/SelectableChannel), Apache Mina, Netty (Java)

  • POE (Perl)

  • Twisted (Python)

相反,boost::asio 和 Windows I/O Completion Ports 实现了 Proactor 模式,应用面似乎要窄一些。当然,ACE 也实现了 Proactor 模式,不表。

在“non-blocking IO + IO multiplexing”这种模型下,程序的基本结构是一个事件循环 (event loop):(代码仅为示意,没有完整考虑各种情况)

while (!done)

{
    
    
  int timeout_ms = max(1000, getNextTimedCallback());
  int retval = ::poll(fds, nfds, timeout_ms);
  if (retval < 0) {
    
    
    // 处理错误

  } else {
    
    
    // 处理到期的 timers
    if (retval > 0) {
    
    
    // 处理 IO 事件
    }
  }
}

当然,select(2)/poll(2) 有很多不足,Linux 下可替换为 epoll,其他操作系统也有对应的高性能替代品(搜 c10k problem)。

Reactor 模型的优点很明显,编程简单,效率也不错。不仅网络读写可以用,连接的建立(connect/accept)甚至 DNS 解析都可以用非阻塞方式进行,以提高并发度和吞吐量 (throughput)。对于 IO 密集的应用是个不错的选择,Lighttpd 即是这样,它内部的 fdevent 结构十分精妙,值得学习。(这里且不考虑用阻塞 IO 这种次优的方案。)

当然,实现一个优质的 Reactor 不是那么容易,我也没有用过坊间开源的库,这里就不推荐了。

2. 典型的多线程服务器的线程模型

这方面我能找到的文献不多,大概有这么几种:

  1. 每个请求创建一个线程,使用阻塞式 IO 操作。在 Java 1.4 引入 NIO 之前,这是 Java 网络编程的推荐做法。可惜伸缩性不佳。

  2. 使用线程池,同样使用阻塞式 IO 操作。与 1 相比,这是提高性能的措施。

  3. 使用 non-blocking IO + IO multiplexing。即 Java NIO 的方式。

  4. Leader/Follower 等高级模式

在默认情况下,我会使用第 3 种,即 non-blocking IO + one loop per thread 模式。

http://pod.tst.eu/http://cvs.schmorp.de/libev/ev.pod#THREADS_AND_COROUTINES

One loop per thread

此种模型下,程序里的每个 IO 线程有一个 event loop (或者叫 Reactor),用于处理读写和定时事件(无论周期性的还是单次的),代码框架跟第 2 节一样。

这种方式的好处是:

  1. 线程数目基本固定,可以在程序启动的时候设置,不会频繁创建与销毁。

  2. 可以很方便地在线程间调配负载。

event loop 代表了线程的主循环,需要让哪个线程干活,就把 timer 或 IO channel (TCP connection) 注册到那个线程的 loop 里即可。

  • 对实时性有要求的 connection 可以单独用一个线程;
  • 数据量大的 connection 可以独占一个线程
  • 把数据处理任务分摊到另几个线程中;
  • 其他次要的辅助性 connections 可以共享一个线程。

对于 non-trivial 的服务端程序,一般会采用 non-blocking IO + IO multiplexing,每个 connection/acceptor 都会注册到某个 Reactor 上,程序里有多个 Reactor,每个线程至多有一个 Reactor。

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

线程池

不过,对于没有 IO 光有计算任务的线程,使用 event loop 有点浪费,我会用有一种补充方案,即用 blocking queue 实现的任务队列(TaskQueue):

blocking_queue<boost::function<void()> > taskQueue;  // 线程安全的阻塞队列
void worker_thread()

{
    
    
  while (!quit) {
    
    
    boost::function<void()> task = taskQueue.take();  // this blocks
    task();  // 在产品代码中需要考虑异常处理
  }
}

用这种方式实现线程池特别容易:

启动容量为 N 的线程池:

int N = num_of_computing_threads;

for (int i = 0; i < N; ++i) {
    
    
  create_thread(&worker_thread);  // 伪代码:启动线程
}

使用起来也很简单:

boost::function<void()> task = boost::bind(&Foo::calc, this);
taskQueue.post(task);

上面十几行代码就实现了一个简单的固定数目的线程池,功能大概相当于 Java 5 的 ThreadPoolExecutor 的某种“配置”。当然,在真实的项目中,这些代码都应该封装到一个 class 中,而不是使用全局对象。

另外需要注意一点:Foo 对象的生命期,我的另一篇博客《当析构函数遇到多线程——C++ 中线程安全的对象回调》详细讨论了这个问题
http://blog.csdn.net/Solstice/archive/2010/01/22/5238671.aspx

除了任务队列,还可以用 blocking_queue<T> 实现数据的消费者-生产者队列,即 T 的是数据类型而非函数对象,queue 的消费者(s)从中拿到数据进行处理。这样做比 task queue 更加 specific 一些。

blocking_queue 是多线程编程的利器,它的实现可参照 Java 5 util.concurrent 里的 (Array|Linked)BlockingQueue,通常 C++ 可以用 deque 来做底层的容器。Java 5 里的代码可读性很高,代码的基本结构和教科书一致(1 个 mutex,2 个 condition variables),健壮性要高得多。如果不想自己实现,用现成的库更好。(我没有用过免费的库,这里就不乱推荐了,有兴趣的同学可以试试 Intel Threading Building Blocks 里的 concurrent_queue。)

归纳

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

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

以这种方式写服务器程序,需要一个优质的基于 Reactor 模式的网络库来支撑,我只用过 in-house 的产品,无从比较并推荐市面上常见的 C++ 网络库,抱歉。

程序里具体用几个 loop、线程池的大小等参数需要根据应用来设定,基本的原则是“阻抗匹配”,使得 CPU 和 IO 都能高效地运作,具体的考虑点容我以后再谈。

这里没有谈线程的退出,留待下一篇 blog“多线程编程反模式”探讨。

此外,程序里或许还有个别执行特殊任务的线程,比如 logging,这对应用程序来说基本是不可见的,但是在分配资源(CPU 和 IO)的时候要算进去,以免高估了系统的容量。

3. 进程间通信与线程间通信

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 配置就能继续用。相反,前面列出的其他 IPC 都不能跨机器(比如共享内存效率最高,但再怎么着也不能高效地共享两台机器的内存),限制了 scalability。

在编程上,TCP sockets 和 pipe 都是一个文件描述符,用来收发字节流,都可以 read/write/fcntl/select/poll 等。不同的是,TCP 是双向的,pipe 是单向的 (Linux),进程间双向通讯还得开两个文件描述符,不方便;而且进程要有父子关系才能用 pipe,这些都限制了 pipe 的使用。在收发字节流这一通讯模型下,没有比 sockets/TCP 更自然的 IPC 了。当然,pipe 也有一个经典应用场景,那就是写 Reactor/Selector 时用来异步唤醒 select (或等价的 poll/epoll) 调用(Sun JVM 在 Linux 就是这么做的)。

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

两个进程通过 TCP 通信,如果一个崩溃了,操作系统会关闭连接,这样另一个进程几乎立刻就能感知,可以快速 failover。当然,应用层的心跳也是必不可少的,我以后在讲服务端的日期与时间处理的时候还会谈到心跳协议的设计。

与其他 IPC 相比,TCP 协议的一个自然好处是可记录可重现,tcpdump/Wireshark 是解决两个进程间协议/状态争端的好帮手。

另外,如果网络库带“连接重试”功能的话,我们可以不要求系统里的进程以特定的顺序启动,任何一个进程都能单独重启,这对开发牢靠的分布式系统意义重大。

使用 TCP 这种字节流 (byte stream) 方式通信,会有 marshal/unmarshal 的开销,这要求我们选用合适的消息格式,准确地说是 wire format。这将是我下一篇 blog 的主题,目前我推荐 Google Protocol Buffers

有人或许会说,具体问题具体分析,如果两个进程在同一台机器,就用共享内存,否则就用 TCP,比如 MS SQL Server 就同时支持这两种通信方式。我问,是否值得为那么一点性能提升而让代码的复杂度大大增加呢?TCP 是字节流协议,只能顺序读取,有写缓冲;共享内存是消息协议,a 进程填好一块内存让 b 进程来读,基本是“停等”方式。要把这两种方式揉到一个程序里,需要建一个抽象层,封装两种 IPC。这会带来不透明性,并且增加测试的复杂度,而且万一通信的某一方崩溃,状态 reconcile 也会比 sockets 麻烦。为我所不取。再说了,你舍得让几万块买来的 SQL Server 和你的程序分享机器资源吗?产品里的数据库服务器往往是独立的高配置服务器,一般不会同时运行其他占资源的程序。

TCP 本身是个数据流协议,除了直接使用它来通信,还可以在此之上构建 RPC/REST/SOAP 之类的上层通信协议,这超过了本文的范围。另外,除了点对点的通信之外,应用级的广播协议也是非常有用的,可以方便地构建可观可控的分布式系统。

猜你喜欢

转载自blog.csdn.net/qq_22473333/article/details/113510924