您的位置:首页 > 编程语言

服务端编程中多线程的应用

2016-08-11 18:52 344 查看
         本文是陈硕的《Linux多线程服务端编程  使用muduo C++网络库》一书中,第三章的读书笔记。其中暗红颜色的文字是自己的理解,鲜红颜色的文字表示原书中需要注意的地方。

 

一:进程和线程

         每个进程有自己独立的地址空间。“在同一个进程”还是“不在同一个进程”是系统功能划分的重要决策点。《Erlang程序设计》[ERL]把进程比喻为人:

         每个人有自己的记忆(内存),人与人通过谈话(消息传递)来交流,谈话既可以是面谈(同一台服务器),也可以在电话里谈(不同的服务器,有网络通信)。面谈和电话谈的区别在于,面谈可以立即知道对方是否死了(crash,SIGCHLD),而电话谈只能通过周期性的心跳来判断对方是否还活着。

         有了这些比喻,设计分布式系统时可以采取“角色扮演”,团队里的几个人各自扮演一个进程,人的角色由进程的代码决定(管登录的、管消息分发的、管买卖的等等)。每个人有自己的记忆,但不知道别人的记忆,要想知道别人的看法,只能通过交谈(暂不考虑共享内存这种IPC)。然后就可以思考:

         ·容错:万一有人突然死了

         ·扩容:新人中途加进来

         ·负载均衡:把甲的活儿挪给乙做

         ·退休:甲要修复bug,先别派新任务,等他做完手上的事情就把他重启

等等各种场景,十分便利。

 

         线程的特点是共享地址空间,从而可以高效地共享数据。一台机器上的多个进程能高效地共享代码段(操作系统可以映射为同样的物理内存),但不能共享数据。如果多个进程大量共享内存,等于是把多进程程序当成多线程来写,掩耳盗铃。

         “多线程”的价值,我认为是为了更好地发挥多核处理器(multi-cores)的效能。在单核时代,多线程没有多大价值(个人想法:如果要完成的任务是CPU密集型的,那多线程没有优势,甚至因为线程切换的开销,多线程反而更慢;如果要完成的任务既有CPU计算,又有磁盘或网络IO,则使用多线程的好处是,当某个线程因为IO而阻塞时,OS可以调度其他线程执行,虽然效率确实要比任务的顺序执行效率要高,然而,这种类型的任务,可以通过单线程的”non-blocking
IO+IO multiplexing”的模型(事件驱动)来提高效率,采用多线程的方式,带来的可能仅仅是编程上的简单而已)。Alan Cox说过:”A computer is a state machine.Threads are for people who can’t program
state machines.”(计算机是一台状态机。线程是给那些不能编写状态机程序的人准备的)如果只有一块CPU、一个执行单元,那么确实如Alan Cox所说,按状态机的思路去写程序是最高效的。

 

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

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

         在”non-blocking IO + IO multiplexing”这种模型中,程序的基本结构是一个事件循环(event loop),以事件驱动(event-driven)和事件回调的方式实现业务逻辑://代码仅为示意,没有完整考虑各种情况
while(!done)
{
int timeout_ms = max(1000, getNextTimedCallback());
int retval = poll(fds, nfds, timeout_ms);
if (retval<0){
处理错误,回调用户的error handler
}else{
处理到期的timers,回调用户的timer handler
if(retval>0){
处理IO事件,回调用户的IO event handler
}
}
}
         这里select(2)/poll(2)有伸缩性方面的不足(描述符过多时,效率较低),Linux下可替换为epoll(4),其他操作系统也有对应的高性能替代品。

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

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

 

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

         大概有这么几种:

         a:每个请求创建一个线程,使用阻塞式IO操作。在Java 1.4引人NIO之前,这是Java网络编程的推荐做法。可惜伸缩性不佳(请求太多时,操作系统创建不了这许多线程)。

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

         c:使用non-blocking IO + IO multiplexing。即Java NIO的方式。

         d:Leader/Follower等高级模式。

         在默认情况下,我会使用第3种,即non-blocking IO + one loop per thread模式来编写多线程C++网络服务程序。

 

1:one loop per thread

         此种模型下,程序里的每个IO线程有一个event  loop,用于处理读写和定时事件(无论周期性的还是单次的)。代码框架跟“单线程服务器的常用编程模型”一节中的一样。

         libev的作者说:

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

 

         这种方式的好处是:

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

         b:可以很方便地在线程间调配负载。

         c:IO事件发生的线程是固定的,同一个TCP连接不必考虑事件并发。

 

         Event loop代表了线程的主循环,需要让哪个线程干活,就把timer或IO channel(如TCP连接)注册到哪个线程的loop里即可:对实时性有要求的connection可以单独用一个线程;数据量大的connection可以独占一个线程,并把数据处理任务分摊到另几个计算线程中(用线程池);其他次要的辅助性connections可以共享一个线程。

         比如,在dbproxy中,一个线程用于专门处理客户端发来的管理命令;一个线程用于处理客户端发来的mysql命令,而与后端数据库通信执行该命令时,是将该任务分配给所有事件线程处理的。

 

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

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

         在dbproxy中,线程向其他线程分发任务,是通过管道和队列实现的。比如主线程accept到连接后,将表示该连接的结构放入队列,并向管道中写入一个字节。计算线程在自己的event
loop中注册管道的读事件,一旦有数据可读,就尝试从队列中取任务。

 

2:线程池

         不过,对于没有IO而光有计算任务的线程,使用event loop有点浪费。可以使用一种补充方案,即用blocking
 queue实现的任务队列:typedef boost::function<void()>Functor;
BlockingQueue<Functor> taskQueue; //线程安全的全局阻塞队列

//计算线程
void workerThread()
{
while (running) //running变量是个全局标志
{
Functor task = taskQueue.take(); //this blocks
task(); //在产品代码中需要考虑异常处理
}
}

// 创建容量(并发数)为N的线程池
int N = num_of_computing_threads;
for (int i = 0; i < N; ++i)
{
create_thread(&workerThread); //启动线程
}

//向任务队列中追加任务
Foo foo; //Foo有calc()成员函数
boost::function<void()> task = boost::bind(&Foo::calc,&foo);
taskQueue.post(task);

         除了任务队列,还可以用BlockingQueue<T>实现数据的生产者消费者队列,即T是数据类型而非函数对象,queue的消费者从中拿到数据进行处理。其实本质上是一样的。

 

3:总结

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

         event  loop用作IO multiplexing,配合non-blockingIO和定时器;

         thread  pool用来做计算,具体可以是任务队列或生产者消费者队列。

 

         以这种方式写服务器程序,需要一个优质的基于Reactor模式的网络库来支撑,muduo正是这样的网络库。比如dbproxy使用的是libevent。

         程序里具体用几个loop、线程池的大小等参数需要根据应用来设定,基本的原则是“阻抗匹配”(解释见下),使得CPU和IO都能高效地运作。所谓阻抗匹配原则:

         如果池中线程在执行任务时,密集计算所占的时间比重为 P (0 < P <= 1),而系统一共有 C 个 CPU,为了让这 C 个 CPU 跑满而又不过载,线程池大小的经验公式 T = C/P。(T 是个 hint,考虑到 P 值的估计不是很准确,T 的最佳值可以上下浮动 50%)

         以后我再讲这个经验公式是怎么来的,先验证边界条件的正确性。

         假设 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。

 

四:进程间通信只用TCP

         Linux下进程间通信的方式有:匿名管道(pipe)、具名管道(FIFO)、POSIX消息队列、共享内存、信号(signals),以及Socket。同步原语有互斥器(mutex)、条件变量(condition variable)、读写锁(reader-writer lock)、文件锁(record locking)、信号量(semaphore)等等。

 

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

         可以跨主机,具有伸缩性。反正都是多进程了,如果一台机器的处理能力不够,很自然地就能用多台机器来处理。把进程分散到同一局域网的多台机器上,程序改改host:port配置就能继续用;

         TCP sockets和pipe都是操作文件描述符,用来收发字节流,都可以read/write/fcntl/select/poll等。不同的是,TCP是双向的,Linux的pipe是单向的,进程间双向通信还得开两个文件描述符,不方便;而且进程要有父子关系才能用pipe,这些都限制了pipe的使用;

         TCP port由一个进程独占,且进程退出时操作系统会自动回收文件描述符。因此即使程序意外退出,也不会给系统留下垃圾,程序重启之后能比较容易地恢复,而不需要重启操作系统(用跨进程的mutex就有这个风险);而且,port是独占的,可以防止程序重复启动,后面那个进程抢不到port,自然就没法初始化了,避免造成意料之外的结果;

         与其他IPC相比,TCP协议的一个天生的好处是“可记录、可重现”。tcpdump和Wireshark是解决两个进程间协议和状态争端的好帮手,也是性能(吞吐量、延迟)分析的利器。我们可以借此编写分布式程序的自动化回归测试。也可以用tcpcopy之类的工具进行压力测试。TCP还能跨语言,服务端和客户端不必使用同一种语言。

 

         分布式系统的软件设计和功能划分一般应该以“进程”为单位。从宏观上看,一个分布式系统是由运行在多台机器上的多个进程组成的,进程之间采用TCP长连接通信。

         使用TCP长连接的好处有两点:一是容易定位分布式系统中的服务之间的依赖关系。只要在机器上运行netstat  -tpna|grep  <port>就能立刻列出用到某服务的客户端地址(Foreign
Address列),然后在客户端的机器上用netstat或lsof命令找出是哪个进程发起的连接。TCP短连接和UDP则不具备这一特性。二是通过接收和发送队列的长度也较容易定位网络或程序故障。在正常运行的时候,netstat打印的Recv-Q和Send-Q都应该接近0,或者在0附近摆动。如果Recv-Q保持不变或持续增加,则通常意味着服务进程的处理速度变慢,可能发生了死锁或阻塞。如果Send-Q保持不变或持续增加,有可能是对方服务器太忙、来不及处理,也有可能是网络中间某个路由器或交换机故障造成丢包,甚至对方服务器掉线,这些因素都可能表现为数据发送不出去。通过持续监控Recv-Q和Send-Q就能及早预警性能或可用性故障。以下是服务端线程阻塞造成Recv-Q和客户端Send-Q激增的例子:$netstat -tn
Proto Recv-Q Send-Q Local Address Foreign
tcp 78393 0 10.0.0.10:2000 10.0.0.10:39748 #服务端连接
tcp 0 132608 10.0.0.10:39748 10.0.0.10:2000 #客户端连接
tcp 0 52 10.0.0.10:22 10.0.0.4:55572

 

五:多线程服务器的适用场合

         如果要在一台多核机器上提供一种服务或执行一个任务,可用的模式有:

         a:运行一个单线程的进程;

         b:运行一个多线程的进程;

         c:运行多个单线程的进程;

         d:运行多个多线程的进程;

 

         考虑这样的场景:如果使用速率为50MB/s的数据压缩库,进程创建销毁的开销是800微秒,线程创建销毁的开销是50微秒。如何执行压缩任务?

         如果要偶尔压缩1GB的文本文件,预计运行时间是20s,那么起一个进程去做是合理的,因为进程启动和销毁的开销远远小于实际任务的耗时。

         如果要经常压缩500kB的文本数据,预计运行时间是10ms,那么每次都起进程      似乎有点浪费了,可以每次单独起一个线程去做。

         如果要频繁压缩10kB的文本数据,预计运行时间是200微秒,那么每次起线程似      乎也很浪费,不如直接在当前线程搞定。也可以用一个线程池,每次把压缩任务交给线程池,避免阻塞当前线程(特别要避免阻塞IO线程)。

         由此可见,多线程并不是万灵丹(silver bullet)。

 

1:必须使用单线程的场合

         据我所知,有两种场合必须使用单线程:

         a:程序可能会fork(2);

         实际编程中,应该保证只有单线程程序能进行fork(2)。多线程程序不是不能调用fork(2),而是这么做会遇到很多麻烦:

         fork一般不能在多线程程序中调用,因为Linux的fork只克隆当前线程的thread
of control,不可隆其他线程。fork之后,除了当前线程之外,其他线程都消失了。

         这就造成一种危险的局面。其他线程可能正好处于临界区之内,持有了某个锁,而它突然死亡,再也没有机会去解锁了。此时如果子进程试图再对同一个mutex加锁,就会立即死锁。因此,fork之后,子进程就相当于处于signal
handler之中(因为不知道调用fork时,父进程中的线程此时正在调用什么函数,这和信号发生时的场景一样),你不能调用线程安全的函数(除非它是可重入的),而只能调用异步信号安全的函数。比如,fork之后,子进程不能调用:

         malloc,因为malloc在访问全局状态时几乎肯定会加锁;

         任何可能分配或释放内存的函数,比如snprintf;

         任何Pthreads函数;

         printf系列函数,因为其他线程可能恰好持有stdout/stderr的锁;

         除了man 7 signal中明确列出的信号安全函数之外的任何函数。

 

         因此,多线程中调用fork,唯一安全的做法是fork之后,立即调用exec执行另一个程序,彻底隔断子进程与父进程的联系。

 

         在多线程环境中调用fork,产生子进程后。子进程内部只存在一个线程,也就是父进程中调用fork的线程的副本。

         使用fork创建子进程时,子进程通过继承整个地址空间的副本,也从父进程那里继承了所有互斥量、读写锁和条件变量的状态。如果父进程中的某个线程占有锁,则子进程同样占有这些锁。问题是子进程并不包含占有锁的线程的副本,所以子进程没有办法知道它占有了哪些锁,并且需要释放哪些锁。

         尽管Pthread提供了pthread_atfork函数试图绕过这样的问题,但是这回使得代码变得混乱。因此《Programming
With Posix Threads》一书的作者说:”Avoid using fork in threaded code except where the child process will immediately exec a new program.”。

 

         b:限制程序的CPU占用率;

         这个很容易理解,比如在一个8核的服务器上,一个单线程程序即便发生busy-wait,占满1个core,其CPU使用率也只有12.5%,在这种最坏的情况下,系统还是有87.5%的计算资源可供其他服务进程使用。

         因此对于一些辅助性的程序,如果它必须和主要服务进程运行在同一台机器的话,那么做成单线程的能避免过分抢夺系统的计算资源。

 

2:单线程程序的优缺点

         从编程的角度,单线程程序的优势在于简单。程序的结构一般就是一个基于IO  multiplexing的event loop。

         但是Event loop有一个明显的缺点,它是非抢占的。假设事件a的优先级高于事件b,处理事件a需要1ms,处理事件b需要10ms。如果事件b稍早于a发生,那么当事件a到来时,程序已经离开了poll(2)调用,并开始处理事件b。事件a要等上10ms才有机会被处理,总的响应时间为11ms。这等于发生了优先级反转。这个缺点可以用多线程来克服,这也是多线程的主要优势。

 

3:适用多线程程序的场景

         多线程相比于单线程、多进程的模式,未必有绝对意义上的性能优势。比如说,如果用很少的CPU负载就能让IO跑满,或者用很少的IO流量就能让CPU跑满,那么多线程没啥用处。举例来说:

         对于静态Web服务器,或者FTP服务器,CPU的负载较轻,主要瓶颈在磁盘IO和网络IO方面。这时往往一个单线程的程序就能撑满IO。用多线程并不能提高吞吐量,因为IO硬件容量已经饱和了。同理,这时增加CPU数目也不能提高吞吐量。

         CPU跑满的情况比较少见,这里虚构一个例子。假设有一个服务,它的输人是n个整数,问能否从中选出m个整数,使其和为0(这里n < 100,m > 0)。这是著名的subset sum问题,是NP-Complete的。对于这样一个“服务”,哪怕很小的n值也会让CPU算死。比如n=30,一次的输人不过200字节(32-bit整数),CPU的运算时间却能长达几分钟。对于这种应用。多进程模式是最适合的,能发挥多核的优势,程序也简单(多线程相比于多进程的优势在于:多线程更轻量,线程间的交互更简单。该场景中,执行任务的时间远大于线程或进程启动销毁的开销,而且这种场景,进程或线程之间也无需交互。所以,多线程并不比多进程更好)。

 

         那么多线程到底适合哪种场景呢?我认为多线程的适用场景是:提高响应速度,让IO和“计算”相互重叠,降低延迟。虽然多线程不能提高绝对性能,但能提高平均响应性能。

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

         有多个CPU可用。单核机器上多线程没有性能优势(但或许能简化并发业务逻辑的实现);

         线程间有共享数据,即内存中的全局状态。如果没有共享数据,用多进程就行;

         共享的数据是可以修改的,而不是静态的常量表。如果数据不能修改,那么采用多进程模式,进程间用共享内存即可;

         提供非均质的服务。即,事件的响应有优先级差异,我们可以用专门的线程来处理优先级高的事件。防止优先级反转;

         延迟和吞吐量同样重要,不是逻辑简单的IO bound或CPU bound程序。换言之,程序要有相当的计算量;

         利用异步操作。比如logging模块。无论往磁盘写log 文件,还是往log server发送消息都不应该阻塞关键路径;

         能扩展。一个好的多线程程序可以享受增加CPU数目带来的好处;

         具有可预测的性能。随着负载增加,性能缓慢下降,超过某个临界点之后会急速下降。线程数目一般不随负载变化;

         多线程能有效地划分责任与功能,让每个线程的逻辑比较简单,任务单一,便于编码。而不是把所有逻辑都塞到一个event loop里,使不同类别的事件之间相互影响。

   

         以上这些条件比较抽象,这里举个具体的例子。

         假设要管理一个Linux服务器机群,这个机群有8个计算节点,1个控制节点。机器的配置都是一样的,双路四核CPU,千兆网互联。现在需要编写一个简单的机群管理软件,这个软件由3个程序组成:

         运行在控制节点上的master,这个程序监视并控制整个机群的状态;

         运行在每个计算节点上的slave,负责启动和终止job,并监控本机的资源;

         供最终用户使用的client命令行工具,用于提交job;

 

         slave是个“看门狗进程”,它就是简单的fork子进程,由子进程exec别的job进程。因此必须是个单线程程序,另外它不应该占用太多的CPU资源,这也适合单线程模型。

         master应该是个多线程程序,因为:

         它独占一台8核的机器,如果用单线程,等于浪费了87.5%的CPU资源;

         整个机群的状态应该能完全放在内存中,这些状态是共享且可变的。如果用多进程模式,那么进程之间的状态同步会成大问题。而如果大量使用共享内存,则等于是掩耳盗铃,是披着多进程外衣的多线程程序。因为一个进程一旦在临界区内阻塞或crash,其他进程会全部死锁。

         master的主要性能指标不是吞吐量,而是延迟,即尽快地响应各种事件。它几乎不会出现把IO或CPU跑满的情况。

         master监控的事件有优先级区别,一个程序正常运行结束和异常崩溃的处理优先级不同,计算节点的磁盘满了和机箱温度过高这两种报警条件的优先级也不同。如果用单线程,则可能会出现优先级反转;

         假设master和每个slave之间用一个TCP连接,那么master采用2个或4个IO线程来处理8个TCP连接能有效地降低延迟;

         master要异步往本地硬盘写log,这要求logging  library有自己的IO线程;

         master有可能要读写数据库,那么数据库连接这个第三方library可能有自己的线程,并回调master的代码;

         master要服务于多个clients,用多线程也能降低客户响应时间。也就是说它可以再用2个IO线程专门处理和clients的通信;

         master还可以提供一个monitor接口,用来广播推送(pushing)机群的状态,这样用户不用主动轮询(polling)。这个功能如果用单独的线程来做,会比较容易实现,不会搞乱其他主要功能;

         因此,master一共开了10个线程:

         4个用于和slaves通信的IO线程;

         1个logging线程;

         1个数据库IO线程;

         2个和clients通信的IO线程;

         1个主线程,用于做些背景工作,比如job调度;

         1个pushing线程,用于主动广播机群的状态;

 

         虽然线程数目略多于core数目,但是这些线程很多时候都是空闲的,可以依赖OS的进程调度来保证可控的延迟。

         综上所述,master用多线程方式编写是自然且高效的。

 

4:多线程模式中,线程的分类

         据我的经验,一个多线程服务程序中的线程大致可分为3类:

         a、IO线程,这类线程的主循环是IO muItiplexing,阻塞地等在select/poll/epoll_wait系统调用上。这类线程也处理定时事件。当然它的功能不止IO,有些简单计算也可以放人其中,比如消息的编码或解码;

         b、计算线程,这类线程的主循环是blocking queue,阻塞地等在条件变量上。这类线程一般位于thread
 pool中。这种线程通常不涉及IO,一般要避免任何阻塞操作;

         c、第三方库所用的线程,比如logging,又比如database
 connection;

 

         服务器程序一般不会频繁地启动和终止线程。甚至,在我写过的程序里,create thread只在程序启动的时候调用,在服务运行期间是不调用的。

         在多核时代,要想充分发挥CPU性能,多线程编程是不可避免的。

 

六:附注

1:Linux能同时启动多少个线程

         对于32位 Linux,一个进程的地址空间是4G,其中用户态能访问3G左右,而一个线程的默认栈大小是10MB(使用”ulimit -a”或”ulimit
-s”命令查看,http://unix.stackexchange.com/questions/127602/default-stack-size-for-pthreads)。因此一个进程大约最多能同时启动300个线程。如果不改线程的调用栈大小的话,300左右是上限,因为程序的其他部分(数据段、代码段、堆、动态库等等)同样要占用内存地址空间。

         对于64位系统,线程数目可大大增加,具体数字我没有测试过,因为我在实际项目中一台机器上最多只用到过几十个用户线程,其中大部分还是空闲的。

 

2:多线程能提高并发度吗?

         由问题1可知,假如单纯采用thread per connection的模型,那么并发连接数最多300,这远远低于基于事件(Reactor模式)的单线程程序所能轻松达到的并发连接数(几千乃至上万,甚至几万)。

         采用前文推荐的one loop per thread模式,至少不逊于单线程程序。实际上单个event loop处理1万个并发长连接并不罕见,一个multi-loop的多线程程序应该能轻松支持5万并发链接。

         因此,thread per connection不适合高并发场合,其扩展性不佳。one loop per thread的并发度足够大,且与CPU数目成正比。

 

3:多线程能提高吞吐量吗?

         假设有一个耗时的计算服务,用单线程算需要0.8s。在一台8核的机器上,启动8个线程一起对外服务(如果内存够用,启动8个进程也一样)。这样完成单个计算仍然要0.8s(每个线程处理一个计算),但是由于这些线程的计算可以同时进行,理想情况下吞吐量可以从单线程的1.25qps(query per
second)上升到10qps(0.8s完成了8个计算。但是实际情况可能要打个八折)。

         假如改用并行算法,用8个核一起算,理论上如果完全并行,加速比高达8,那么计算时间是0.1s,吞吐量还是10qps。但是首次请求的响应时间却降低了很多。实际上根据Amdahl's haw,即便算法的并行度高达95%,8核的加速比也只有6,计算时间为0.133s,这样会造成吞吐量下降为7.5qps。不过以此为代价,换得响应时间的提升,在有些应用场合也是值得的。

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

 

         如果用thread per request的模型,每个客户请求用一个线程去处理,那么当并发请求数大于某个临界值T时,吞吐量反而会下降,因为线程多了以后上下文切换的开销也随之增加(分析与数据请见《A Design Framework for Highly Concurrent Systems》)。但是thread per request是最简单的使用线程的方式,编程最容易,简单地把多线程程序当成一堆串行程序,用同步的方式顺序编程。

         为了在并发请求数很高时也能保持稳定的吞吐量,我们可以用线程池,线程池的大小应该满足“阻抗匹配原则”。

         但是线程池也不是万能的,如果响应一次请求需要做比较多的计算(比如计算的时间占整个response time的1/5强),那么用线程池是合理的,能简化编程。如果在一次请求响应中,主要时间是在等待IO,那么为了进一步提高吞吐量,往往要用其他编程模型,比如Proactor。

 

4:多线程能降低响应时间吗?

         如果设计合理,充分利用多核资源的话,多线程可以降低响应时间。

         例1:多线程处理输入以memcached服务端为例。memcached一次请求响应大概可以分为3步:读取并解析客户端输人;操作hashtable;返回客户端。

         在单线程模式下,这3步是串行执行的。在启用多线程模式时,它会启用多个输人线程(默认是4个),并在建立连接时按round-robin法把新连接分派给其中一个输人线程,这正好是我说的one loop per thread模型。这样一来,第1步的操作就能多线程并行,在多核机器上提高多用户的响应速度。第2步用了全局锁,还是单线程的,这可算是一个值得继续改进的地方。

         比如,有两个用户同时发出了请求,这两个用户的连接正好分配在两个IO线程上,那么两个请求的第1步操作可以在两个线程上并行执行,然后汇总到第2步串行执行,这样总的响应时间比完全串行执行要短一些(在“读取并解析”所占的比重较大的时候,效果更为明显)。

 

         例2:假设我们要做一个求解数独的服务,这个服务程序在9981端口接受请求,输人为一行81个数字(待填数字用0表示),输出为填好之后的81个数字,如果无解,输出“NO\r\n"。

         由于输人格式很简单,用单个线程做IO就行了。先假设每次求解的计算用时为10ms,用前面的方法计算,单线程程序能达到的吞吐量上限为100qps;在8核机器上,如果用线程池来做计算,能达到的吞吐量上限为800qps。

         下面我们看看多线程如何降低响应时间。

         假设1个用户在极短的时间内发出了10个请求,如果用单线程“来一个处理一个”的模型,这些请求会排在队列里依次处理(这个队列是操作系统的TCP缓冲区,不是程序里自己的任务队列)。在不考虑网络延迟的情况下,第1个请求的响应时间是10ms;第2个请求要等第1个算完了才能获得CPU资源,它等了10ms,算了10ms,响应时间是20ms;依此类推,第10个请求的响应时间为100ms。这10个请求的平均响应时间为55ms(10个请求,总的响应时间为10+20+30+…+100=550ms,因此平均响应时间为55ms,这是从用户角度来看的)。

         改用多线程:1个IO线程,8个计算线程(线程池)。二者之间用BlockingQueue沟通。同样是10个并发请求,第1个请求被分配到计算线程1,第2个请求被分配到计算线程2,依此类推,直到第8个请求被第8个计算线程承担。第9和第10号请求会等在BlockingQueue里,直到有计算线程回到空闲状态其才能被处理。

         这里的分配实际上由操作系统来做,操作系统会从处于waiting状态的线程里挑一个,不一定是round-robin的。这样一来,前8个请求的响应时间差不多都是10ms,后2个请求属于第二批,其响应时间大约会是20ms,总的平均响应时间是12ms(10个请求,总的响应时间为10+10+10+…+10+20+20=120ms,因此平均响应时间为12ms)。可以看出这比单线程快了不少。

         由于每道数独题目的难度不一,对于简单的题目,可能1ms就能算出来,复杂的题目最多用10rns。那么线程池方案的优势就更明显,它能有效地降低“简单任务被复杂任务压住”的出现概率。

 

5:多线程程序如何让IO和“计算”相互重叠,降低latency ?

         基本思路是,把IO操作(通常是写操作)通过BlockingQueue交给别的线程去做,自己不必等待。

         比如在多线程服务器程序中,日志模块(logging)至关重要。本例仅考虑写log file的情况,不考虑log server。

         在一次请求响应中,可能要写多条日志消息,而如果用同步的方式写文件(fprintf或fwrite),多半会降低性能,因为:

         文件操作一般比较慢,服务线程会等在IO上,让CPU闲置,增加响应时间;

         就算有buffer,还是不灵。多个线程一起写,为了不至于把buffer写错乱,往往要加锁。这会让服务线程互相等待,降低并发度。

 

         解决办法是单独用一个logging线程,负责写磁盘文件,通过一个或多个BlockingQueue对外提供接口。别的线程要写日志的时候,先把消息准备好,然后往queue里一塞就行,基本不用等待。这样服务线程的计算就和logging线程的磁盘IO相互重叠,降低了服务线程的响应时间。

         尽管logging很重要,但它不是程序的主要逻辑,因此对程序的结构影响越小越好,最好能简单到如同一条printf语句,且不用担心其他性能开销。而一个好的多线程异步logging库能帮我们做到这一点。
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: