• 本文内容衔接于前一篇文章(单线程、多线程服务器的适用场合):javascript:void(0)
  • 《多线程服务器的适用场合》一文登出后(javascript:void(0)),有很多读者提出了质疑,本文对一些多线程服务器中的一些疑问进行解答。以下“连接、端口”均指TCP协议
一、Linux能同时启动多少个线程?
  • 对于32-bit Linux:
    • 一个进程的地址空间是4GiB,其中用户态能访问 3GiB左右,而一个线程的默认栈大小是10MB,心算可知,一个进程大约最多能同时启动300个线程
    • 如果不改线程的调用栈大小的话,300左右是上限,因为程序的其他部分(数据段、代码段、堆、动态库等等)同样要占用内存(地址空间)
  • 对于64-bit系统:
    • 线程数目可大大增加,具体数字我没有测试过, 因为我在实际项目中一台机器上最多只用到过几十个用户线程,其中大部分还是空闲的
  • 下面的第2问关于线程数目的讨论以32-bit Linux为例
二、多线程能提高并发度吗?
  • 如果指的是“并发连接数”,则不能
  • 由问题1可知:
    • 假如单纯采用thread per connection的模型,那么并发连接数最多300,这远远低于基于事件的单线程程序所能轻松达到的并发连接数(几千乃至 g上万,甚至几万)。所谓“基于事件”,指的是用IO multiplexing event loop的编程模型,又称Reactor模式,在前文中已有介绍
    • 那么采用前文中推荐的one loop per thread呢?至少不逊于单线程程序。实际上单个event loop处理1万个并发长连接并不罕见,一个multi-loop的多线程程序应该能轻松支持5万并发链接
  • 小结:
    • thread per connection不适合高并发场合,其scalability不佳
    • one loop per thread的并发度足够大,且与CPU数目成正比
三、多线程能提高吞吐量吗?
  • 对于计算密集型服务,不能

例如

  • 假设有一个耗时的计算服务:
    • 用单线程算需要0.8s:
      • 在一台8核的机器上,我们可以启动8个线程一起对外服务(如果内存够用,启动8个进程也一样)
      • 这样完成单个计算仍然要0.8s,但是由于这些进程的计算 可以同时进行,理想情况下吞吐量可以从单线程的1.25qps(query per second)上升到10qps。(实际情况可能要打个八折——如果不是打对折的话)
    • 假如改用并行算法,用8个核一起算:
      • 理论上如果完全并行,加速比高达8,那么计算时间是0.1s,吞吐量还是10qps,但是首次请求的响应时间却降低了很多
      • 实际上根据Amdahl's law,即便算法的并行度高达95%,8核的加速比也只有6,计算时间为0.133s,这样会造成吞吐量下降为7.5qps。不过以此为代价,换得响应时间的提升,在有些应用场合也是值得的

例如

  • 再举一个例子,如果要在一台8核机器上压缩100个1GB的文本文件,每个core的处理能力为200MB/s
  • 那么:
    • “每次起8个进程,每个进程压缩1个文件”“依次压缩每个文件,每个文件用8个线程并行压缩”这两种方式的总耗时相当,因为CPU都是满载的
    • 但是第2种方式能较快地拿到第一个压缩完的文件,也就是首次响应的延时更小

这也回答了问题4

thread per request的模型

  • 如果用thread per request的模型:
    • 每个客户请求用一个线程去处理, 那么当并发请求数大于某个临界值T′时,吞吐量反而会下降,因为线程多了以后上下文切换的开销也随之增加
    • 分析与数据请见《A Design Framework for Highly Concurrent Systems》by Matt Welsh at al.https://cs.berkeley.edu/~culler/papers/events.pdf
    • thread per request是最简单的使用线程的方式,编程最容易,简单地把多线程程序当成一堆串行程序,用同步的方式顺序编程,比如在Java Servlet 2.x中,一次页面请求由一个下面的函数同步地完成

muduo网络库:10---多线程服务器之(“多线程服务器的适用场合”的例释与答疑)_响应时间

  • 为了在并发请求数很高时也能保持稳定的吞吐量,我们可以用线程池:
    • 线程池的大小应该满足“阻抗匹配原则”,见问题7
    • 线程池也不是万能的:
      • 如果响应一次请求需要做比较多的计算(比如计算的时间占整个response time的1/5强),那么用线程池是合理的, 能简化编程
      • 如果在一次请求响应中,主要时间是在等待IO,那么为了进一步提高吞吐量,往往要用其他编程模型,比如Proactor,见问题8
四、多线程能降低响应时间吗?
  • 如果设计合理,充分利用多核资源的话,可以。在突发(burst)请求时效果尤为明显

例1:多线程处理输入

  • 以memcached服务端为例。memcached一次请求响应大概可以分为3步:
    • 1.读取并解析客户端输入
    • 2.操作hashtable
    • 3.返回客户端
  • 在单线程模式下,这3步是串行执行的
  • 在启用多线程模式时,它会启用多个输入线程(默认是4个),并在建立连接时按round-robin(循环)法把新连接分派给其中一个输入线程,这正好是我说的one loop per thread模型。这样一来,第1步的操作就能多线程并行,在多核机器上提高多用户的响应速度。第2步用了全局锁,还是单线程的,这可算是一个值得继续改进的地方
    • 比如,有两个用户同时发出了请求,这两个用户的连接正好分配在两个IO线程上,那么两个请求的第1步操作可以在两个线程上并行执行,然后汇总到第2步串行执行,这样总的响应时间比完全串行执行要短一些(在“读取并解析”所占的比重较大的时候,效果更为明显)
  • 请继续看下面这个例子

例2:多线程分担负载

  • 假设我们要做一个求解Sudoku的服务(javascript:void(0)),这个服务程序在9981端口接受请求,输入为一行81个数字(待填数字用0表示),输出为填好之后的81个数字(1~9),如果无解,输出“NO\r\n”。
  • 由于输入格式很简单,用单个线程做IO就行了:
    • 先假设每次求解的计算用时为10ms,用前面的方法计算,单线程程序能达到的吞吐量上限 为100qps;在8核机器上,如果用线程池来做计算,能达到的吞吐量上 限为800qps。下面我们看看多线程如何降低响应时间
    • 假设1个用户在极短的时间内发出了10个请求,如果用单线程“来一 个处理一个”的模型,这些reqs会排在队列里依次处理(这个队列是操作系统的TCP缓冲区,不是程序里自己的任务队列)。在不考虑网络延迟 的情况下,第1个请求的响应时间是10ms;第2个请求要等第1个算完了 才能获得CPU资源,它等了10ms,算了10ms,响应时间是20ms;依此 类推,第10个请求的响应时间为100ms;这10个请求的平均响应时间为55ms
    • 如果Sudoku服务在每个请求到达时开始计时,会发现每个请求都是 10ms响应时间;而从用户的观点来看,10个请求的平均响应时间为55ms,请读者想想为什么会有这个差异
  • 下面改用多线程:
    • 1个IO线程,8个计算线程(线程池)。二者之间 用BlockingQueue沟通。同样是10个并发请求,第1个请求被分配到计算 线程1,第2个请求被分配到计算线程2,依此类推,直到第8个请求被第 8个计算线程承担。第9和第10号请求会等在BlockingQueue里,直到有 计算线程回到空闲状态其才能被处理。(请注意,这里的分配实际上由 操作系统来做,操作系统会从处于waiting状态的线程里挑一个,不一定 是round-robin的。)这样一来,前8个请求的响应时间差不多都是 10ms,后2个请求属于第二批,其响应时间大约会是20ms,总的平均响 应时间是12ms。可以看出这比单线程快了不少
    • 由于每道Sudoku题目的难度不一,对于简单的题目,可能1ms就能 算出来,复杂的题目最多用10ms。那么线程池方案的优势就更明显,它 能有效地降低“简单任务被复杂任务压住”的出现概率
  • 以上举的都是计算密集的例子,即线程在响应一次请求时不会等待 IO。下面谈谈更复杂的情况
五、多线程程序如何让IO和“计算”相互重叠,降低latency?
  • 基本思路是,把IO操作(通常是写操作)通过BlockingQueue交给别的线程去做,自己不必等待

例1:日志(logging)

  • 在多线程服务器程序中,日志(logging)至关重要,本例仅考虑写log file的情况,不考虑log server
  • 在一次请求响应中,可能要写多条日志消息,而如果用同步的方式写文件(fprintf或fwrite),多半会降低性能,因为:
    • 文件操作一般比较慢,服务线程会等在IO上,让CPU闲置,增加 响应时间
    • 就算有buffer,还是不灵。多个线程一起写,为了不至于把buffer 写错乱,往往要加锁。这会让服务线程互相等待,降低并发度。(同时 用多个log文件不是办法,除非你有多个磁盘,且保证log files分散在不 同的磁盘上,否则还是要受到磁盘IO瓶颈的制约)
  • 解决办法是单独用一个logging线程,负责写磁盘文件,通过一个或多个BlockingQueue对外提供接口。别的线程要写日志的时候,先把消息(字符串)准备好,然后往queue里一塞就行,基本不用等待。这样服务线程的计算就和logging线程的磁盘IO相互重叠,降低了服务线程的响应时间
  • 尽管logging很重要,但它不是程序的主要逻辑,因此对程序的结构影响越小越好,最好能简单到如同一条printf语句,且不用担心其他性 能开销。而一个好的多线程异步logging库能帮我们做到这一点,见第5 章。(Apache的log4cxx和log4j都支持AsyncAppender这种异步logging方 式。)

例2:memcached客户端

  • 假设我们用memcached来保存用户最后发帖的时间,那么每次响应用户发帖的请求时,程序里要去设置一下memcached里的值。这一步如果用同步IO,会增加延迟
  • 对于“设置一个值”这样的write-only idempotent操作,我们其实不用等memcached返回操作结果,这里也不用在乎set操作失败,那么可以借 助多线程来降低响应延迟。比方说我们可以写一个多线程版的 memcached的客户端,对于set操作,调用方只要把key和value准备好, 调用一下asyncSet()函数,把数据往BlockingQueue上一放就能立即返 回,延迟很小。剩下的事就留给memcached客户端的线程去操心,而服务线程不受阻碍
  • 其实所有的网络写操作都可以这么异步地做,不过这也有一个缺 点,那就是每次asyncWrite()都要在线程间传递数据。其实如果TCP缓冲 区是空的,我们就可以在本线程写完,不用劳烦专门的IO线程。Netty 就使用了这个办法来进一步降低延迟
  • 以上都仅讨论了“打一枪就跑”的情况,如果是一问一答,比如从 memcached取一个值,那么“重叠IO”并不能降低响应时间,因为你无论 如何要等memcached的回复。这时我们可以用别的方式来提高并发度, 见问题8。(虽然不能降低响应时间,但也不要浪费线程在空等上)
  • 以上的例子也说明,BlockingQueue是构建多线程程序的利器。另见“C++经验谈之“用STL algorithm轻松解决几道算法面试题””
六、为什么第三方库往往要自己的线程?
  • event loop模型没有标准实现。如果自己写代码,尽可以按所用 Reactor的推荐方式来编程。但是第三方库不一定能很好地适应并融入这 个event loop framework,有时需要用线程来做一些串并转换。比方说检 测串口上的数据到达可以用文件描述符的可读事件,因此可以方便地融 入event loop。但是检测串口上的某些控制信号(例如DCD)只能用轮 询(ioctl(fd, TIOCMGET, &flags))或阻塞等待(ioctl(fd, TIOCMIWAIT, TIOCM_CAR));要想融入event loop,需要单独起一个线程来查询串口信号翻转,再转换为文件描述符的读写事件(可以通过pipe)
  • 对于Java,这个问题还好办一些,因为thread pool在Java里有标准实现,叫ExecutorService。如果第三方库支持线程池,那么它可以和主程序共享一个ExecutorService,而不是自己创建一堆线程。(比如在初始化时传入主程序的obj)
  • 对于C++,情况麻烦得多,Reactor和thread pool都没有标准库

例1:libmemcached只支持同步操作

  • libmemcached支持所谓的“非阻塞操作”,但没有暴露一个能被select/poll/epoll的file describer,它的memcached_ fetch始终会阻塞。它号称memcached_set可以是非阻塞的, 实际意思是不必等待结果返回,但实际上这个函数会阻塞地调用write,仍可能阻塞在网络IO上
  • 如果在我们的Reactor event handler里调用了libmemcached的函数, 那么latency就堪忧了。如果想继续用libmemcached,我们可以为它做一 次线程封装,按问题5例2的办法,同额外的线程专门做memcached的 IO,而程序主体还是Reactor。甚至可以把memcached的“数据就绪”作为 一个event,注入我们的event loop中,以进一步提高并发度。(例子留待问题8讲)
  • 万幸的是,memcached的协议非常简单,大不了可以自己写一个基 于Reactor的客户端,但是数据库客户端就没那么幸运了。

例2:MySQL的官方C API不支持异步操作

  • MySQL的官方客户端只支持同步操作:
    • 对于UPDATE/INSERT/DELETE之类只要行为不管结果的操作(如果代码需要得知其执行结果,则另当别论),我们可以用一个单独的线程来做,以降低服务线程的延迟
    • 可仿照前面memcached_set的例子,不再赘言。麻烦的是SELECT,如果要把它也异步化,就得动用更复杂的模式了,见问题8
    • 非官方的libdrizzle似乎支持异步操作:http://github.com/chaoslawful/drizzle-nginx-module
  • 相比之下,PostgreSQL的C客户端libpq的设计要好得多,我们可以用PQsendQuery()来发起一次查询,然后用标准的select/poll/epoll来等待PQsocket。如果有数据可读,那么用PQconsumeInput处理之,并用 PQisBusy判断查询结果是否已就绪。最后用PQgetResult来获取结果。借 助这套异步API,我们可以很容易地为libpq写一套wrapper,使之融入程 序所用的event loop模型中
七、什么是线程池大小的阻抗匹配原则?
  • 我在前文中提到“阻抗匹配原则”,这里大致讲一讲
  • 如果池中线程在执行任务时,密集计算所占的时间比重为P(0< P≤1),而系统一共有C个CPU,为了让这C个CPU跑满而又不过载,线 程池大小的经验公式T=C/P。T是个hint,考虑到P值的估计不是很准 确,T的最佳值可以上下浮动50%.这个经验公式的原理很简单,T个线 程,每个线程占用P的CPU时间,如果刚好占满C个CPU,那么必有T×P =C。下面验证一下边界条件的正确性
  • 假设C=8,P=1.0,线程池的任务完全是密集计算,那么T=8。只 要8个活动线程就能让8个CPU饱和,再多也没用,因为CPU资源已经耗 光了
  • 假设C=8,P=0.5,线程池的任务有一半是计算,有一半等在IO 上,那么T=16。考虑操作系统能灵活、合理地调度sleeping/writing/running线程,那么大概16个“50%繁忙的线程”能让8个 CPU忙个不停。启动更多的线程并不能提高吞吐量,反而因为增加上下 文切换的开销而降低性能
  • 如果P<0.2,这个公式就不适用了,T可以取一个固定值,比如 5×C。另外,公式里的C不一定是CPU总数,可以是“分配给这项任务的 CPU数目”,比如在8核机器上分出4个核来做一项任务,那么C=4。
八、除了推荐的Reactor+thread pool,还有别的non-trivial多线程编程模型吗?
  • 有,Proactor。如果一次请求响应中要和别的进程打多次交道,那么Proactor模型往往能做到更高的并发度。当然,代价是代码变得支离破碎,难以理解

这里举HTTP proxy为例

  • 一次HTTP proxy的请求如果没有命中本地cache,那么它多半会:
    • 1.解析域名(不要小看这一步,对于一个陌生的域名,解析可能 要花几秒的时间)
    • 2.建立连接
    • 3.发送HTTP请求
    • 4.等待对方回应
    • 5.把结果返回给客户
  • 这5步中跟2个server发生了3次round-trip,每次都可能花几百毫秒:
    • 1.向DNS问域名,等待回复
    • 2.向对方的HTTP服务器发起连接,等待TCP三路握手完成
    • 3.向对方发送HTTP request,等待对方response
  • 而实际上HTTP proxy本身的运算量不大,如果用线程池,池中线程的数目会很庞大,不利于操作系统的管理调度
  • 这时我们有两个解决思路:
    • 1.把“域名已解析”、“连接已建立”、“对方已完成响应”做成 event,继续按照Reactor的方式来编程。这样一来,每次客户请求就不 能用一个函数从头到尾执行完成,而要分成多个阶段,并且要管理好请 求的状态(“目前到了第几步?”)。
    • 2.用回调函数,让系统来把任务串起来。比如收到用户请求,如 果没有命中本地缓存,那么需要执行:
      • a.立刻发起异步的DNS解析startDNSResolve(),告诉系统在解析完 之后调用DNSResolved()函数
      • b.在DNSResolved()中,发起TCP连接请求,告诉系统在连接建立 之后调用connectionEstablished()
      • c.在connectionEstablished()中发送HTTP request,告诉系统在收到 响应之后调用httpResponsed()
      • d.最后,在httpResponsed()里把结果返回给客户
    • NET大量采用的BeginInvoke/EndInvoke操作也是这个编程模式。当 然,对于不熟悉这种编程方式的人,代码会显得很难看。有关Proactor 模式的例子可参看Boost.Asio的文档,这里不再多说
  • Proactor模式依赖操作系统或库来高效地调度这些子任务,每个子任务都不会阻塞,因此能用比较少的线程达到很高的IO并发度
  • Proactor能提高吞吐,但不能降低延迟,所以我没有深入研究。另外,在没有语言直接支持的情况下(有的语言能通过库扩展,例如http://jscex.info/zh-cn),Proactor模式让代码非常破碎,在 C++中使用Proactor是很痛苦的。因此最好在“线程”很廉价的语言中使用 这种方式,这时runtime往往会屏蔽细节,程序用单线程阻塞IO的方式来 处理TCP连接。
九、模式2和模式3a该如何取舍?
  • 前一篇文章中提到,模式2是一个多线程的进程,模式3a是多个相同的单线程进程
  • 我认为,在其他条件相同的情况下,可以根据工作集 的大小来取舍。工作集是指服务程序响应一次请求所访问的内存大小
  • 如果工作集较大,那么就用多线程,避免CPU cache换入换出,影响性能;否则,就用单线程多进程,享受单线程编程的便利。举例来说:
    • 如果程序有一个较大的本地cache,用于缓存一些基础参考数据 (in-memory look-up table),几乎每次请求都会访问cache,那么多线 程更适合一些,因为可以避免每个进程都自己保留一份cache,增加内 存使用。
    • memcached这个内存消耗大户用多线程服务端就比在同一台机器上 运行多个memcached instance要好。(但是如果你在16GiB内存的机器上运行32-bit memcached,那么此时多instance是必需的。)
    • 求解Sudoku用不了多大内存。如果单线程编程更方便的话,可以用单线程多进程来做。再在前面加一个单线程的load balancer,仿 lighttpd+fastcgi的成例。
  • 线程不能减少工作量,即不能减少CPU时间。如果解决一个问题需要执行一亿条指令(这个数字不大,不要被吓到),那么用多线程只会让这个数字增加。但是通过合理调配这一亿条指令在多个核上的执行情 况,我们能让工期提早结束。这听上去像统筹方法,其实也确实是统筹方法