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

  • 进程间通信与线程同步;
  • 以最简单规范的方式开发功能正确、线程安全的多线程程序;
  • 多线程服务器是指运行在linux操作系统上的独占式网络应用程序;
  • 不考虑分布式存储, 只考虑分布式计算;

进程与线程

  • 进程(process)是操作系统里最重要的两个概念之一(另一个是文件), 粗略的讲, 一个进程是"内存中正在运行的程序";
  • 每个进程有自己独立的地址空间(adress space), "在同一个进程"还是"不在同一个进程"是系统功能划分的重要决策点;
  • 把进程比喻成人, 电话谈只能通过周期性的心跳来判断对方是否还活着;
    • 容错, 万一有人突然死了;
    • 扩容, 新人中途加进来;
    • 负载均衡, 把甲的活挪给乙做;
    • 退休, 甲要修复bug, 先别派新任务, 等他做完手上的事情就把他重启等等各种场景, 十分便利;
  • 线程的特点是共享地址空间, 从而可以高效地共享数据;
  • 如果多个进程大量共享内存, 等于是把多进程程序当成多线程来写, 掩耳盗铃;
  • "多线程"的价值, 是为了更好地发挥多核处理器(multi-cores)的效能;
  • 单核用状态机的思路去写程序是最高效的;

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

  • I/O模型, 客户端/服务器设计范式;
    • 用得最广的是"non-blocking IO + IO multiplexing"这种模型(非阻塞IO+IO多路复用), 即Reactor模式;
      • lighttpd, 单线程服务器(Nginx与之类似, 每个工作进程都有一个eventloop事件循环);
      • libevent, libev;
      • ACE, Poco C++ libraries;
      • Java NIO, 包括Apache Mina 和 Netty;
      • POE(Perl);
      • Twisted(Python);
    • "non-blocking IO + IO multiplexing"这种模型(非阻塞IO+IO多路复用)中, 程序的基本结构是一个事件循环(event loop), 以事件驱动(event-driven)和事件回调的方式实现业务逻辑;
    • select/poll有伸缩性方面的不足, Linux下用epoll来进行替换;
    • Reactor模型的优点很明显, 编程不难, 效率也不错;
      • 不仅可以用于读写socket, 连接的建立(connect/accept)甚至DNS解析都可以用非阻塞方式进行;
      • 以提高并发度和吞吐量(throught), 对于IO密集的应用是个不错的选择;
      • lighttpd内部的fdevent结构十分精妙, 值得学习;
    • 基于事件驱动的编程模型也有其本质的缺点, 它要求事件回调函数必须是非阻塞的;
    • 对于涉及网络IO的请求响应式协议, 它容易割裂业务逻辑, 使其散布于多个回调函数中, 相对不容易理解和维护;

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

  • 大概有几种:
    • 每请求创建一个线程, 使用阻塞式IO操作; 可惜伸缩不佳;
    • 使用线程池, 同样使用阻塞式IO操作, 这是提高性能的措施;
    • 使用non-blocking IO + IO multiplexing; 即Java NIO的方式;
    • Leader/Follower等;
  • 默认情况下, 使用non-blocking IO + IO multiplexing模式来编写多线程C++网络服务程序;
    • 线程数目基本固定, 可以在程序启动的时候设置, 不会频繁创建与销毁;
    • 可以很方便地在线程间调配负载;
    • IO事件发生的线程是固定的, 同一个TCP链接不必考虑事件并发;
    • Eventloop代表了线程的主循环, 需要让哪个线程干活, 就把timer或IO channel(如TCP连接)注册到哪个线程的loop里即可;
  • 多线程程序对event loop提出了更高的要求, 那就是线程安全;
    • 多线程的Reactor;

线程池

  • 对于没有IO光有计算任务的线程, 使用event loop有点浪费, 一种补充方案是用blocking queue实现的任务队列(TaskQueue);
  • BlockingQueue
  • 无界的BlockingQueue和有界的BoundedBlockingQueue;
    • Intel Threading Building Blocks里的concurrent_queue
  • 推荐模式:
    • 推荐的C++多线程服务器模式为: one(event) loop per thread + thread pool;
    • event loop(也叫IO loop)用作IO multiplexing, 配合non-blocking IO和定时器;
    • thread pool用来做计算, 具体可以是任务队列或是生产者消费者队列;
    • 写这种方式的服务器程序, 需要一个优质的基于Reactor模式的网络库来支撑, muduo正是这样的网络库;

进程间通信只用TCP

  • IPC(进程间通信)主要有: 匿名管道(pipe), 有名管道(FIFO), POSIX消息队列, 共享内存, 信号(signals), 套接字(sockets), 信号量(semaphore);
  • 同步原语(synchronization primitives): 互斥量(mutex), 条件变量(condition variable), 读写锁(reader-writer lock), 文件锁(record locking), 信号量(semaphore);
  • 贵精不贵多;
  • TCP是双向的, Linux的pipe是单向的;
  • pipe有一个经典的应用场景, 那就是写Reactor/event loop时用来异步唤醒select(或等价的poll/epoll_wait)调用;
  • TCP port由一个进程独占, 且操作系统会自动回收(listening port和已建立连接的TCP socket都是文件描述符, 在进程结束时操作系统会关闭所有文件描述符);
    • 快速failover(故障容错), 应用层的心跳也必不可少;
  • TCP协议的一个天生的好处是"可记录, 可重现";
  • TCP连接是可再生的, 连接的任何一方都可以退出再启动, 重建连接之后就能继续工作, 对开发牢靠的分布式系统意义重大;
  • TCP这种字节流(byte stream)方式通信, 会有marshal/unmarshal(编码/解码)的开销;
    • 这要求我们选用合适的消息格式(准确地说是wire format-有线格式), 推荐 Google Protocol Buffers;
  • TCP的local吞吐量一点都不低;
  • TCP是字节流, 只能顺序读取, 有写缓冲;
    • 共享内存是消息协议, a进程填好一块内存让b进程来读, 基本是"stop wait(停等)"方式;
    • 要将这两种方式揉到一个程序里, 需要建立一个抽象层, 封装两个IPC;
      • 这会增加测试的复杂度(因为不透明);
    • 生产环境下的数据库服务器往往是独立的高配置服务器, 一般不会同时运行其他占资源的程序;
  • TCP是个数据流协议, 除了直接使用它通信外, 还可以在此之上构建RPC/HTTP/SOAP之类的上层通信协议;
  • 除点对点的通信之外, 应用级的广播协议也是非常有用的, 可方便地构建可观可控的分布式系统;

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

  • 分布式的软件设计和功能划分一般以"进程"为单位;
  • 分布式系统采用TCP长连接通信;
  • 必要时可以借助多线程来提高性能;
  • 对整个分布式系统, 要做到能scale out, 即享受增加机器带来的好处;
  • 使用TCP长连接的好处有两点:
    • 容易定位分布式系统中的服务器之间的依赖关系;
      • netstat -tpna | grep : port能立即列出客户端地址;
    • 二是通过接收和发送队列的长度也较容易定位网络或程序故障;

多线程服务器的适用场合

  • 开发服务器端程序的一个基本任务是处理并发连接, 现在服务端网络编程处理并发连接主要有两种方式:
    • 当线程很廉价时, 一台机器上可以创建远高于CPU数目的线程;
    • 当线程很宝贵时, 一台机器上只能创建与CPU数目相当的线程;
  • 必须用单线程的场合:
    • 程序可能fork;
    • 限制程序的CPU占用率;
  • 一个程序fork之后, 一般有两种行为:
    • 立刻执行exec(), 变身为另一个程序(负责启动job的守护进程);
    • 不调用exec(), 继续运行当前程序;
      • 要么通过共享的文件描述符与父进程通信, 协同完成任务;
      • 要么接过父进程传来的文件描述符, 完成独立的任务;
  • 只有看门狗线程必须坚持单线程, 其他的均可替代为多线程程序(从功能上讲);
  • 单线程程序能限制程序的CPU占用率;
    • 做成单线程的能避免过分抢夺系统的计算资源;
  • 单线程程序的优缺点:
    • 单线程程序的优势: 简单, 一个基于IO multiplexing的event loop;
    • event loop有一个明显的缺点, 它是非抢占式的(non-preemptive), 有点类似优先级反转;
      • 这个缺点可以用多线程来克服, 这也是多线程的主要优势;
  • 多线程一般没有性能上的优势:
    • 用很少的CPU负载就能让IO跑满, 或者用很少的IO流量就能让CPU跑满, 那么多线程就没有啥用处;
  • 适用多线程程序的场景:
    • 提高响应速度, 让IO和计算互相重叠, 降低latency(延迟);
    • 虽然多线程不能提高绝对性能, 但多线程能提高平均响应性能;
    • 一个程序要想做多线程, 大致要满足:
      • 有多个CPU可用;(单核机器上多线程没有性能优势, 或许能简化并发业务逻辑的实现);
      • 线程间有共享数据, 即内存中的全局状态, 如果没有共享数据, 用运行多个单线程的进程就行;
      • 提供非均质的服务; 事件的响应有优先级的差异, 用专门的线程来处理优先级高的事件, 防止优先级反转;
      • latency和throughout同样重要, 不是逻辑简单的IO bound或CPU bound程序(程序有相当的计算量);
      • 利用异步操作;
      • 能scale up, 一个好的线程程序能享受增加CPU数目带来的好处;
      • 具有可预测的性能, 线程数一般不随负载变化;
      • 多线程能有效地划分责任和功能, 让每个线程的逻辑比较简单, 任务单一, 便于编码;
  • 线程的分类:
    • IO线程, 这类线程的主循环是IO multiplexing, 阻塞地等在select/poll/epoll_wait系统调用上;
    • 计算线程, 这类的主循环是blocking queue, 阻塞地等待在condition variable上, 这类线程主要位于thread pool中;
    • 第三方库所用的线程, 比如logging, 或database connection;
  • 学习多线程编程还有一个好处, 即训练异步思维, 提高分析并发事件的能力;
    • 运行在多台机器上的服务进程本质上是异步的;
    • 熟悉多线程编程的话, 很容易就能发现分布式系统在消息和事件处理方面的race condition;

多线程服务器的适用场合

  • 32位Linux, 一个进程的地址空间是4GB, 其中用户态能访问3GB左右, 而一个线程的默认栈(stack)大小是10MB, 一个进程大概能开300个线程;
  • 所谓基于事件指的是用IO multiplexing event loop的编程模型, 又称Reactor模式;
  • 单个的event loop处理1万个并发长连接并不稀罕, 一个multi-loop的多线程程序应该能轻松支持5万并发连接;
  • thread per connection 不适合高并发场合, 其scalability不佳, one loop per thread 的并发度足够大, 且与CPU数目成正比;
  • 多线程能提高吞吐量吗?
    • 对于计算密集型服务, 不能;
    • 为了在并发请求数很高时也能保持稳定额吞吐量, 我们可以用线程池, 线程池的大小应该满足"阻抗匹配原则";
    • 线程池也不是万能的, 如果响应一次请求需要做比较多的计算可以用线程池;
    • 如果一次请求响应中, 主要时间是等待IO, 那么为了进一步提高吞吐量, 往往要用其他编程模型, 比如Proactor;
  • 多线程能降低响应时间么?
    • 如果设计合理, 充分利用多核资源的话, 多线程可以降低响应时间, 在突发(burst)请求时效果尤为明显;
  • 多线程程序如何让IO和计算互相重叠, 降低latency(延迟)?
    • 把IO操作(通常是写操作)通过BlockingQueue交给别的线程去做, 自己不必等待;
  • 为什么第三方库往往要用自己的线程?
    • event loop模型没有标准实现;
    • libmemcached只支持同步操作;
    • MySQL的官方C API不支持异步操作;
  • 什么是线程池大小的阻抗匹配原则?
    • 线程池的经验公式T=C/P;
    • 考虑操作系统能灵活、合理地调度sleeping/writing/running线程;
  • 除了你推荐的Reactor+thread pool, 还有别的non-trivial多线程编程模型吗?
    • 有, Proactor, 如果一次请求响应中要和别的进程打多次交道, 那么Proactor模型往往能做到更高的并发度;
    • Proactor模式依赖操作系统或库来高效地调度这些子任务, 每个子任务都会阻塞, 因此能用比较少的线程达到很高的IO并发度;
    • Proactor能提高吞吐, 但不能降低延迟;
    • Proactor模式让代码非常破碎, 在C++中使用Proactor是很痛苦的;
  • 多线程的进程和多个相同的单线程进程如何取舍?
    • 在其他条件相同的情况下, 可以根据工作集(work set)的大小来取舍;
    • 工作集是指服务程序响应一次请求访问内存大小;
    • 如果工作集较大, 那么就用多线程, 避免CPU cache换入换出, 影响性能;否则, 就用单线程多进程, 享受单线程编程的便利;
    • 线程不能减少工作量, 即不能减少CPU时间;
10-10 22:51