安徽科技学院,做移动网站优化快速排名软件,网站建设资讯站,公司网站开发实例本文中的多线程服务器指运行在Linux上的独占式网络应用程序。硬件平台为Intel x86-64系列的多核CPU#xff0c;单路或双路SMP#xff08;Symmetric Multi-Processing#xff0c;对称多处理#xff0c;它是一种多核处理器架构#xff0c;其中多个CPU核心共享系统的内存和其…本文中的多线程服务器指运行在Linux上的独占式网络应用程序。硬件平台为Intel x86-64系列的多核CPU单路或双路SMPSymmetric Multi-Processing对称多处理它是一种多核处理器架构其中多个CPU核心共享系统的内存和其他资源以协同执行并行计算任务服务器每台机器一共拥有四个核或八个核十几GB内存机器之间用千兆以太网连接。这大概是作者写作时民用PC服务器的主流配置。不考虑分布式存储只考虑分布式计算系统的规模大约是几十台到几百台服务器之间。
进程process粗略地讲是内存中正在运行的程序。本书的进程指的是Linux操作系统通过fork系统调用产生的东西或Windows下CreateProcess()的产物不是Erlang里那种轻量级进程Actor。
每个进程有自己独立的地址空间address space在同一个进程还是不在同一个进程是系统功能划分的重要决策点。《Erlang程序设计》把进程比作人十分精当。
每个人有自己的记忆memory人与人通过谈话消息传递来交流谈话既可以是面谈同一台服务器也可以在电话里谈不同服务器有网络通信。面谈和电话谈的区别在于面谈可以立即知道对方是否死了crashSIGCHLD而电话谈只能通过周期性的心跳来判断对方是否还活着。
有了这些比喻设计分布式系统时可以采取角色扮演团队里的几个人各自扮演一个进程人的角色由进程的代码决定管登录的、管消息分发的、管买卖的等等每个人有自己的记忆但不知道别人的记忆要想知道别人的看法只能通过交谈暂不考虑共享内存这种IPC。然后就可以思考以下场景 1.容错万一有人死了。
2.扩容新人中途加进来。
3.负载均衡把甲的活挪给乙做。
4.退休甲要修复bug先别派任务等他做完手上的事情就把它重启。
线程概念大概在1993年后才慢慢流行起来距今作者写作不到20年比不得有40年光辉历史的Unix操作系统。线程的出现给Unix添了不少乱很多C库函数strtok()、ctome()不是线程安全的需要重新定义。signal的语意也大为复杂化。最早支持多线程的民用操作系统是Solaris 2.2和Windows NT 3.1它们均发布于1993年随后在1995年POSIX threads标准确立。
线程的特点是共享地址空间从而可以高效地共享数据。一台机器上的多个进程能高效地共享代码段操作系统可以映射为同样的物理内存但不能共享数据。如果多个进程大量共享内存等于是把多进程程序当成多线程来写掩耳盗铃。
多线程的价值作者认为是为了更好地发挥多核处理器multi-cores的效能。在单核时代多线程没有多大价值。Alan Cox说过A computer is a state machine. Threads are for people who can’t program state machines.计算机是一台状态机。线程是给那些不能编写状态机程序的人准备的。这句话中Alan Cox暗示如果程序员完全理解并能有效地编程状态机他们就不需要使用线程。如果只有一块CPU、一个执行单元那么确实如Alan Cox所说按状态机的思路去写程序是最高效的。
对于单线程服务器的编程模型UNP对此有很好的总结第六章的IO模型、第三十章的客户端/服务器设计范式这里不再赘述。在高性能的网络程序中使用得最为广泛的恐怕是non-blocking IO IO multiplexing模型即Reactor模式有以下程序使用它 1.lighttpd单线程服务器。Nginx与之类似每个工作进程有一个event loop
2.libeventlibev。
3.ACEPoco C libraries。
4.Java NIO包括Apache Mina和Netty。
5.POEPerl。
6.TwistedPython。
相反Boost.Asio和Windows I/O Completion Ports实现了Proactor模式应用面似乎要窄一些。此外ACE也实现了Proactor模式。
在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 handlerif (retval 0){// 处理IO时间回调用户的IO event handler}}
}以上代码中select/poll有伸缩性方面的不足Linux下可替换为epoll其他操作系统也有对应的高性能替代品。
Reactor模型的优点很明显编程不难效率也不错。不仅可用于读写socket连接的建立甚至DNS解析gethostbyname函数是阻塞的对陌生域名的解析的耗时可长达数秒我们可以显式发起DNS解析然后异步等待DNS服务器的响应都可以用非阻塞方式进行以提高并发度和吞吐量throughput对于IO密集型的应用是个不错的选择。lighttpd就是这样。
基于事件驱动的编程模型也有其本质缺点它要求事件回调函数必须是非阻塞的。对于设计网络IO的请求响应式协议它容易割裂业务逻辑使其散布于多个回调函数之中相对不容易理解和维护。现代的语言有一些应对方法如coroutine但本书只关注C这种传统语言C20引入了coroutine。
多线程服务器的常用编程模型 1.每个请求创建一个线程使用阻塞式IO操作。Java 1.4引入NIO前这是Java网络编程的推荐做法可惜伸缩性不佳。
2.使用线程池同样使用阻塞式IO操作与1相比这是提高性能的措施。
3.使用non-blocking IO IO multiplexing即Java NIONew I/O它是Java平台的一组API用于支持高性能、非阻塞的I/O操作方式。
4.Leader/Follower等高级模式。
默认作者推荐第3种方式来编写多线程C网络服务程序。
此种模型下程序里的每个IO线程有一个event loop或者叫Reactor用于处理读写和定时事件无论是周期性的还是单次的代码框架跟单线程的non-blocking IO IO multiplexing模型一样。
libev不是libevent的作者说One loop per thread is usually a good model. Doing this is a almost never wrong, sometimes a better-performance model exists, but it is always a good start.。
这种方式的好处是 1.线程数目基本固定可以在程序启动时设置不会频繁创建与销毁。
2.可以很方便地在线程间调配负载。
3.IO事件发生的线程是固定的同一个TCP连接不用考虑事件并发。
Event loop代表了线程的主循环需要让哪个线程干活就把timer或IO channel如TCP连接注册到哪个线程的loop里即可。对实时性有要求的connection可以单独用一个线程数据量大的connection可以独占一个线程并把数据处理任务分摊到另几个计算线程中用线程池其他次要的辅助性connections可以共享一个线程。
对于non-trivial比较复杂的通常处理大量IO请求的服务端程序一般会采用non-blocking IO IO multiplexing每个connection/acceptor都会注册到某个event loop上程序里有多个event loop每个线程至多有一个event loop。
多线程程序对event loop提出了更高的要求即线程安全要允许一个线程往别的线程里塞东西例如主IO线程收到一个新建连接分配给某个子IO线程处理这个loop必须得是线程安全的。
对于没有IO而只有计算任务的线程使用event loop有点浪费可以用blocking queue实现的任务队列
typedef boost::functionvoid Functor;
// 线程安全的阻塞队列
BlockingQueueFunctor taskQueue;void workerThread()
{// running是个全局标志while (running){// this blocksFunctor task taskQueue.tack();// 在产品代码中需要考虑异常处理task();}
}这种方式实现线程池特别容易以下是启动容量并发数为N的线程池
int N num_of_computing_threads;
for (int i 0; i N; i)
{// 伪代码启动线程create_thread(workerThread);
}使用起来也很简单
// Foo有calc成员函数
Foo foo;
boost::functionvoid() task boost::bind(Foo::calc, foo);
taskQueue.post(task);上面简单实现了一个固定数目的线程池功能大概相当于Java中的ThredPoolExecutor的某种“配置”相当于其某种配置下的行为。在真实项目中这些代码都应该封装到一个class中而不是使用全局对象。
muduo的线程池比这个略复杂因为要提供stop()操作。
除了用BlockingQueueT实现任务队列还可以用BlockingQueueT实现数据的生产者消费者队列即T是数据类型而非函数对象queue的消费者从中拿到数据进行处理。
BlockingQueueT是多线程编程的利器它的实现可参照Java.util.concurrent里的Array|LinkedBlockingQueue这份Java代码可读性很高代码的基本结构和教科书一致一个mutex2个condition variablemutex用于保护队列的访问两个条件变量中一个用于通知当队列不为空时可以取出元素另一个用于通知当队列不满时可以插入元素健壮性高。如果不想自己实现用线程的库更好。muduo里有一个基本的实现包括无界的BlockingQueue和有界的BoundedBlockingQueue两个class。
作者推荐C多线程服务端编程模式为one (event) loop per thread thread pool 1.event loop也叫IO loop用作IO multiplexing配合non-blocking IO和定时器。
2.thread pool用来做计算具体可以是任务队列或生产者消费者队列。
程序里具体用几个loop、线程池的大小等参数需要根据应用来设定原本的原则是阻抗匹配即系统组件之间的资源或处理速度能够匹配使得CPU和IO都能高效地运作。
程序里可能还有个别执行特殊任务的线程如logging这对应用程序来说基本是不可见的但在分配资源CPU和IO时要算进入以免高估了系统的容量。
Linux下进程间通信IPC的方式数不胜数光UNPv2列出的就有匿名管道pipe、具名管道FIFO、POSIX消息队列、共享内存、信号signals等更不必说Sockets了。同步原语synchronization primitives也很多如互斥器mutex、条件变量condition variable、读写锁reader-writer lock、文件锁record locking、信号量semaphore等。
如何选择呢根据作者经验贵精不贵多认真挑选三四样东西就能完全满足作者的工作需要。
进程间通信作者推荐Sockets主要指TCP其最大的好处在于可以跨主机具有伸缩性。反正都是多进程了如果一台机器的处理能力不够很自然地就能用多台机器来处理。把进程分散到同一局域网的多台机器上程序改改host::port配置就能继续用。而前面列出的其他IPC都不能跨机器这限制了scalability。
在编程上TCP sockets和pipe都是操作文件描述符用来收发字节流都可以read/write/fcntl/select/poll等不同的是TCP是双向的Linux的pipe是单向的进程间双向通信还得开两个文件描述符不方便可用socketpair函数代替且进程要有父子关系才能用pipe这都限制了pipe的使用。在收发字节流这一通信模型下没有必要Sockets/TCP更自然的IPC了。当然pipe也有一个经典应用场景即写Reactor/event loop时用来异步唤醒select或等价的poll/epoll_wait函数Sun HotSpot JVM在Linux就是这么做的。
TCP port由一个进程独占且操作系统会自动回收listening port和已建立连接的TCP socket都是文件描述符在进程结束时操作系统会关闭所有文件描述符。这说明即使程序意外退出也不会给系统留下垃圾程序重启后能比较容易地恢复而不需要重启操作系统用跨进程的mutex就有这个风险。既然port是独占的可以防止程序重复启动后面那个进程抢不到port自然就没法初始化了避免造成意料之外的结果。
两个进程通过TCP通信如果一个崩溃了操作系统会关闭连接另一个进程几乎立刻就能感知可以快速failover。当然应用层的心跳也是必不可少的。
与其他IPC相比TCP协议的一个天生的好处是可记录、可重现tcpdump和Wireshark是解决两个进程间协议和状态争端的好帮手也是性能吞吐量、延迟分析的利器。我们可以借此编写分布式程序的自动化回归测试软件测试的一种方法用于检查新的软件版本或代码修改是否引入了新的缺陷同时确保现有功能仍然按预期工作这种类型的测试旨在验证软件在经历更改后是否回归到了以前的稳定状态。也可以用tcpcopy之类的工具进行压力测试。TCP还能跨语言服务端和客户端不必用同一种语言。
如果网络库带连接重试功能我们可以不要求系统里的进程以特定顺序启动任何一个进程都能单独重启即TCP连接是可再生的连接的任何一方都可以退出再启动重建连接后就能继续工作这对开发牢靠的分布式系统意义重大。
使用TCP这种字节流方式通信会有marshal/unmarshal在TCP通信中发送方需要将数据从应用程序的数据结构例如对象或结构体编码成字节序列这个过程称为marshal而接收方需要将接收到的字节序列解码为应用程序可理解的数据结构这个过程称为unmarshal的开销这要求我们选用合适的消息格式准确地说是wire format。
有人可能会说具体问题具体分析如果两个进程在同一台机器就用共享内存否则就用TCP如MS SQL Server就同时支持这两种通信方式。试问是否值得为那么一点性能提升而让代码的复杂度大大增加呢何况TCP的local吞吐量一点也不低。TCP是字节流协议只能顺序读取有写缓冲共享内存是消息协议a进程填好一块内存让b进程来读基本是停等stop wait方式要把这两种方式揉到一个程序里需要建一个抽象层封装两种IPC这会带来不透明性且增加测试的复杂度。且万一通信的某一方崩溃状态reconcile表示在两个不同的状态或数据之间进行协调、调解、调和或修复以使它们一致也会比sockets麻烦数据刚写到一半怎么办。再说你舍得让几万块买来的SQL Server和其他应用程序共享机器资源吗生产环境下的数据库服务器往往是独立的高配置服务器一般不会同时运行其他占资源的程序。
TCP本身是个数据流协议除了直接用它来通信外还可在此只上构建RPC/HTTP/SOAPSimple Object Access Protocol是一种基于XML的通信协议最初被设计用于在分布式系统中实现Web服务通信但它可以用于各种不同的应用和通信方式之类的上层通信协议。另外除了点对点通信外应用级的广播协议也是非常有用的可以方便构建可观可控的分布式系统。
分布式系统的软件设计和功能划分一般应该以进程为单位从宏观上看一个分布式系统是由运行在多台机器上的多个进程组成的进程之间采用TCP长连接通信。本章讨论分布式系统中单个服务进程的设计方法第9章会谈到整个系统的设计。作者提倡用多线程不是说把整个系统放到一个进程里实现而是指功能划分后在实现每一类服务进程时在必要时可以借助多线程来提高性能。对于整个分布式系统要做到能scale out即享受增加机器带来的好处。
使用TCP长连接的好处有以下两点 1.容易定位分布式系统中的服务之间的依赖关系。只要在机器上运行netstat -tpna | grep :port就能立刻列出用到某服务的客户端地址Foreign列然后在客户端的机器上用netstat或lsof命令找出是哪个进程发起的连接这样在迁移服务器的时候能有效地防止出现outage指服务中断或停机特指服务器或服务无法正常运行导致不可用的状态。TCP短连接和UDP则不具备这一特性。
2.通过接收和发送队列的长度也较容易定位网络或程序故障。正常运行时netstat打印的Recv-Q和Send-Q都应该接近0或在0附近摆动。如果Recv-Q保持不变或持续增加则通常意味着服务进程的处理速度变慢可能发生了死锁或阻塞。如果Send-Q保持不变或持续增加有可能是对方服务器太忙来不及处理也有可能是网络中间某个路由器或交换机故障造成丢包甚至对方服务器掉线这些因素都可能表现为数据发送不出去。通过持续监控Recv-Q和Send-Q就能及早预警性能或可用性故障。以下是服务端线程阻塞造成Recv-Q和客户端Send-Q激增的例子 用一句话形容本书所指的服务器开发是跑在多核机器上的Linux用户态的没有用户界面的长期运行不是7 X 24小时不重启而是程序不会因为无事可做而退出它会等待下一个请求的到来的网络应用程序通常是分布式系统的组成部件。
开发服务端程序的一个基本任务是处理并发连接服务器处理并发连接有两种方式 1.当“线程”很廉价时一台机器上可以创建远高于CPU数目的“线程”。这时一个线程只处理一个TCP连接甚至半个通常使用阻塞IO。如Python gevent、Go goroutine、Erlang actor。这里的“线程”由语言的runtime自行调用与操作系统线程不是一回事。
2.当线程很宝贵时一台机器上只能创建与CPU数目相当的线程这时一个线程要处理多个TCP连接上的IO通常使用非阻塞IO和IO multplexing。如libevent、muduo、Netty。这时原生线程能被操作系统的任务调度器看见。
在处理并发连接时要充分发回硬件资源的作用不能让CPU资源闲置。由于本书讨论的是C变成我们只考虑上述第2种方式这是Linux下使用native语言编写用户态高性能网络程序的最成熟的模式。
我们的线程指的是pthread_create()的产物是宝贵的那种原生线程且作者指的Pthreads是NPTLNative POSIX Thread Library这是Linux操作系统上实现POSIX线程标准的一种库的每个线程由cloneclone是一种在Linux上创建新线程的系统调用产生对应一个内核的task_struct每个线程都对应于Linux内核中的一个task_struct数据结构task_struct包含了线程的各种信息如寄存器状态、进程标识符、调度信息等。
一个由多台机器组成的分布式系统必然是多进程的因为进程不能跨OS边界在这个前提下我们把目光集中到一台机器一台拥有至少4个核的普通服务器如果要在一台多核服务器是哪个提供一种服务或执行一个任务可用的模式不是pattern指一种可重复使用的、通用的解决问题的方法或设计而是model指对某个事物、概念或过程的抽象和表示它们的中译是一样的有 1.运行一个单线程的进程。
2.运行一个多线程的进程。
3.运行多个单线程的进程。
4.运行多个多线程的进程。
这些模型之间的比较 1.模式1是不可伸缩的不能发挥多核机器的计算能力。
2.模式3是目前公认的主流模式它有以下两种子模式 3a简单地把模式1中的进程运行多份如果能用多个TCP port对外提供服务的话。
3b主进程worker进程如果必须绑定到一个TCP port如httpdfastcgi。
3.模式2是被很多人所鄙视的认为多线程程序难写且与模式3相比没有什么优势。
4.模式4更是千夫所指它不但没有结合2和3的优点反而汇聚了二者的缺点。
我们讨论模式2和模式3b的优劣即什么时候一个服务器程序应该是多线程的。从功能上讲没有什么是多线程能做到而单线程做不到的反之亦然都是状态机。从性能上讲无论是IO bound还是CPU bound的服务多线程都没有什么优势。
Paul E. McKenney在《Is Parallel Programming Hard, And, If So, What Can You Do About It?》中指出“As a rough rule of thumb, use the simplest tool that will get the job done“作为一个粗略的经验法则应该使用最简单的工具来完成任务。比如说使用速率为50MB/s的数据压缩库在进程创建销毁的开销是800us线程创建销毁开销是50us的前提下考虑如何执行压缩任务 1.如果是要偶尔压缩1GB的文本文件预计运行时间是20s那么起一个进程去做是合理的因为进程启动和销毁的开销远远小于实际任务的耗时。
2.如果要经常压缩500kB的文本数据预计运行时间是10ms那么每次都起进程似乎有点浪费了可以每次单独起一个线程去做。
3.如果要频繁压缩10kB的文本数据预计运行时间是200us那么每次起线程似乎也很浪费不如直接在当前线程搞定。也可以用一个线程池每次把压缩任务交给线程池避免阻塞当前线程特别要避免阻塞IO线程。
由此可见多线程并不是silver bullet它有适用的场合在回答什么时候该用多线程前先谈谈必须用单线程的场合 1.程序可能会fork。
2.限制程序的CPU占用率。
只在单线程程序中fork否则会遇到很多麻烦如果子进程由调用fork()的线程创建那么子进程中只包括一个调用线程但父进程的整个虚拟地址空间都会在子进程中进行复制包括互斥锁、条件变量和其他pthreads对象的状态为了处理由此可能引发的问题可以使用pthread_atfork()。
一个程序fork后一般有两种行为 1.立刻执行exec()变成另一个程序如shell和inetd。又比如lighttpd fork()出子进程然后运行fastcgi程序。还有集群中运行在计算节点上的负责启动job的守护进程即作者所谓的看门口进程。
2.不调用exec()继续运行当前程序要么通过共享的文件描述符与父进程通信协同完成任务要么接过父进程传来的文件描述符独立完成工作如20世纪80年代的Web服务器NCSA httpd。
这两种行为里只有看门狗进程必须坚持单线程其他的均可替换为多线程程序从功能上讲。
对于单线程程序能限制程序的CPU占用率也很容易理解如在一个8核服务器上一个单线程程序即便发生busy-wait无论是因为bug还是因为overload占满1个core其CPU使用率也只有12.5%。在最坏的情况下系统还是有87.5%的计算资源可供其他服务进程使用。
因此对于一些辅助性的程序如果它必须和主要服务进程运行在同一台机器如它要监控其他服务进程的状态那么做成单线程的能避免过分抢夺系统的计算资源。如如果要把生产服务器上的日志文件压缩后备份到NFS上那么应该是用普通单线程压缩工具gzip/bzip2它们对系统造成的影响较小在8核服务器上最多占满1个core。如果有人为了提高速度开启了多线程压缩或同时起多个进程来压缩多个日志文件有可能造成的结果是非关键任务耗尽了CPU资源正常客户的请求响应变慢。
从编程角度单线程程序的优势无需赘言简单。程序的结构一般是一个基于IO multiplexing的event loop或直接用阻塞IO。
event loop有一个明显的缺点它是非抢占non-preemptive的。假设事件a的优先级高于事件b处理事件a需要1ms处理事件b需要10ms。如果事件b稍早于a发生那么当事件a到来时程序已经离开了poll调用并开始处理事件b。事件a要等上10ms才有机会被处理总的响应时间为11ms。这等于发生了优先级反转。这个缺点可以用多线程来克服这也是多线程的主要优势。
无论是IO bound还是CPU bound的服务多线程都没有绝对意义上的性能优势这句话是说如果用很少的CPU负载就能让IO跑满或者用很少的流量就能让CPU跑满那么多线程没啥用处。例如 1.对于静态Web服务器或FTP服务器CPU的负载较轻主要瓶颈在磁盘IO和网络IO方面。这时候往往一个单线程的程序就能撑满IO指占用完IO资源。用多线程并不能提高吞吐量因为IO硬件容量已经饱和了。同理这时增加CPU数目也不能提高吞吐量。
2.CPU跑满的情况比较少见这里举一个虚构的例子。假设有一个服务它的输入是n个整数问能否从中选出m个整数使其和为0这里n100m0。这是著名的subset sum问题是NP-Complete在多项式时间内找不到解的。对于这样一个服务哪怕很小的n值也会让CPU算死。比如n30一次的输入不过200字节32-bit整数CPU的运算时间却能长达几分钟。对于这种应用模式3a是最适合的能发挥多核优势程序也简单。
即无论任何一方早早地到达瓶颈多线程程序都没啥优势。
多线程的适用场景是提高响应速度让IO和计算相互重叠降低latency。虽然多线程不能提高绝对性能但能提高平均响应性能。
一个程序要做成多线程的大致要满足 1.有多个CPU可用。单核机器上多线程没有性能优势但或许能简化并发业务逻辑的实现。
2.线程间有共享数据即内存中的全局状态。如果没有共享数据用模型3b就行。虽然我们应该把线程间的共享数据降到最低但不代表没有。
3.共享的数据是可以修改的而不是静态的常量表。如果数据不能修改那么可以在进程间用shared memory模式3就能胜任。
4.提供非均质的服务。即事件的响应有优先级差异我们可以用专门的线程来处理优先级高的事件防止优先级反转。
5.latency和throughput同样重要不是逻辑简单的IO bound或CPU bound程序。换言之程序要有相当的计算量否则计算量小单线程收到请求后直接就完成了不需要交给其他线程去做。
6.利用异步操作。如logging。无论往磁盘写log file还是往log server发消息都不应阻塞critical path。
7.能scale up。一个好的多线程程序能享受增加CPU数目带来的好处目前主流是8核很快就会用到16核的机器了。
8.具有可预测的性能。随着负载增加性能缓慢下降超过某个临界点后会急速下降。线程数目一般不随负载变化。
9.多线程能有效地划分责任与功能让每个线程的逻辑比较简单任务单一便于编码。而不是把所有逻辑都塞到一个event loop里不同类别的事件之间相互影响。
以上条件比较抽象这里举两个具体虽然是虚构的的例子假设要管理一个Linux服务器机群这个机群里有8个计算节点1个控制节点。机器的配置都是一样的双路四核CPU双路指服务器的主板motherboard上搭载了两个CPU插槽这意味着每台服务器具有两颗CPU每颗CPU都有四个核心千兆网互联。现在要编写一个简单的机群管理软件这个软件由3个程序组成 1.运行在控制节点上的master这个程序监视并控制整个机群的状态。
2.运行在每个计算节点上的slave负责启动和终止job并监控本机的资源。
3.供最终用户使用的client命令行工具用于提交job。
根据以上分析slave是个看门狗进程它会启动别的job进程因此必须是个单线程程序。另外它不应占用太多CPU资源这也适合单线程模型。master应该是个模式2的多线程程序 1.它独占一台8核机器如果用模型1等于浪费了87.5%的CPU资源。
2.整个机群的状态应该能完全放在内存中这些状态是共享且可变的。如果用模式3那么进程之间的状态同步会成大问题。而如果大量使用共享内存则等于是掩耳盗铃是披着多进程外衣的多线程程序。因为一个进程一旦在临界区内阻塞或crush其他进程会全部死锁。
3.master的主要性能指标不是throughput而是latency即尽快响应各种事件。它几乎不会出现把IO或CPU跑满的情况。
4.master监控的事件有优先级区别一个程序正常运行结束和异常崩溃的处理优先级不同计算节点的磁盘满和机箱温度过高这两种报警条件的优先级也不同。如果用单线程则可能会出现优先级反转。
5.假设master和每个slave之间用一个TCP连接那么master采用2个或4个IO线程来处理8个TCP connections能有效降低延迟。
6.master要异步地往本地硬盘写log这要求logging library有自己的IO线程。
7.master有可能要读写数据库那么数据库连接这个第三方library可能有自己的线程并回调master的代码。
8.master要服务于多个clients用多线程也能降低客户响应时间。即它可以再用2个IO线程专门处理和clients的通信。
9.master还可以提供一个monitor接口用来广播推送pushing机群的状态这样用户不用主动轮询polling。这个功能如果用单独的线程来做会比较容易实现不会搞乱其他主要功能。
10.master一共开了十个线程 14个用于和slaves通信的IO线程。
21个logging线程。
31个数据库IO线程。
42个和clients通信的IO线程。
51个主线程用于做些背景工作如job调度。
6一个pushing线程用于主动广播机群状态。
11.虽然线程数目略多于core数目但是这些线程很多时候都是空闲的可以依赖OS的进程调度来保证可控的延迟。
综上所述master用多线程方式编写是自然且高效的。
再举一个TCP聊天服务器的例子这里的聊天不完全指人与人聊天也可能是机器与机器“聊天”。这种服务的特点是并发连接之间有数据交换从一个连接收到的数据要转发给其他多个连接。因此我们不能按模式3的做法把多个连接分到多个进程中分别处理这会带来复杂的进程间通信而只能用模式1或模式2。如果纯粹只有数据交换那么模式1也能工作得很好因为现在的CPU足够快单线程应付几百个连接不在话下。
如果功能进一步复杂化加上关键字过滤、黑名单、防灌水等功能甚至要给聊天内容自动加上相关连接每一项功能都会占用CPU资源。这时就要考虑模式2了因为单个CPU的处理能力显得捉襟见肘顺序处理导致消息转发的延迟增加。这时我们考虑把空闲的多个CPU利用起来自然的做法是把连接分散到多个线程上例如按round-robin的方式把1000个客户连接分配到4个IO线程上。这样充分利用多核加速。
一个多线程服务程序中的线程大致可分为三类 1.IO线程这类线程的主循环是IO multiplexing阻塞地等在select/poll/epoll_wait系统调用上这类线程也处理定时事件。当然它的功能不止IO有些简单计算也可放入其中如消息的编码或解码。
2.计算线程这类线程的主循环是blocking queue阻塞地等在condition variable上。这类线程一般位于thread pool中。这种线程通常不涉及IO一般要避免任何阻塞操作。
3.第三方库所用的线程比如logging、database connection。
服务器程序一般不会频繁启动和终止线程甚至作者写过的程序里create thread只在程序启动时调用在服务运行期间是不调用的。
在多核时代要想充分发挥CPU性能多线程编程是不可避免的“鸵鸟算法”不是办法。学习多线程编程可以训练异步思维提高分析并发事件的能力这对设计分布式系统帮助巨大因为运行在多台机器上的服务进程本质上是异步的。熟悉多线程编程的话很容易就能发现分布式系统在消息和事件处理方面的race condition。
答疑 1.Linux能同时启动多少线程对于32-bit Linux一个进程的地址空间是4GiB其中用户态能访问3GiB左右而一个线程的默认栈stack大小是10MB心算可知一个进程大约最多能同时启动300个线程。如果不改线程的调用栈大小300左右是上限因为程序的其他部分数据段、代码段、堆、动态库等同样要占用内存地址空间。
对于64-bit系统线程数目可大大增加具体数字没有测试过在作者在实际项目中一台机器最多只用到过几十个用户线程其中大部分还是空闲的。
2.多线程能提高并发度吗如果指的是并发连接数则不能。
由问题1可知假如单纯采用thread per connection的模型那么并发连接数量最多300远低于基于事件的单线程程序能轻松达到的并发连接数几千乃至上万甚至几万。所谓“基于事件”指的是用IO multiplexing event loop的编程模型又称Reactor模式。
如果采用前文推荐的one loop per thread呢至少不逊于单线程程序。实际上单个event loop处理1万个并发长连接并不罕见一个multi-loop的多线程程序应该能轻松支持5万并发连接。
thread per connection不适合高并发场合其scalability不佳。one loop per thread的并发度足够大且与CPU数目成正比。
3.多线程能提高吞吐量吗对于计算密集型服务不能。
假设有一个耗时的计算服务用单线程算需要0.8s。在一台8核机器上我们可以同时启动8个线程一起对外服务如果内存够用如果内存够用启动8个进程也一样线程与进程相比吞吐量大致相同。这样完成单个计算仍然需要0.8s但是由于这些进程的计算可以同时进行理想情况下吞吐量可以从单线程的1.25qpsquery per second上升到10qps实际情况可能要打个五折到八折。
假如不用多线程而改用并行算法用8个核一起算理论上如果完全并行加速比高达8那么计算时间是0.1s吞吐量还是10qps但首次请求的响应时间却降低了很多。实际上根据Amdahl’s law阿姆达尔定律是一种用于衡量并行计算性能和效率的数学模型由计算机科学家Gene Amdahl在1967年提出它用于描述在一个计算任务中如果只有一部分可以并行化处理那么整体性能提升会受到限制的情况即便算法的并行度高达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是最简单的使用线程的方式编程最容易简单地把多线程程序当成一堆串行程序用同步的方式顺序编程比如在Java Servlet 2.x中一次页面请求由一个函数同步完成
HttpServlet.service(HttpServletRequest req, HttpServletResponse resp)为了在并发请求数很高时也能保持稳定的吞吐量我们可以用线程池线程池的大小应满足“阻抗匹配原则”见问题7。
线程池也不是万能的如果响应一次请求需要所比较多的计算如计算的时间占整个response time的1/5多那么用线程池是合理的能简化编程。如果在一次请求响应中主要时间是在等待IO那么为了进一步提高吞吐量往往要用其他编程模型如Proactor异步IO见问题8。
4.多线程能降低响应时间吗如果设计合理充分利用多核资源的话可以。在突发burst请求时效果尤为明显。
例1多线程处理输入。以memcached服务端为例。memcached一次请求响应大概分为3步 1读取并解析客户端输入。
2操作hashtable。
3返回客户端。
在单线程模式下这3步是串行执行的。在启用多线程模式时它会启用多个输入线程默认4个并在建立连接时按round-robin法把新连接分派给其中一个输入线程这正好是作者说的one loop per thread模型。这样一来第一步的操作就能多线程并行在多核机器上提高多用户的响应速度。第2步用了全局锁还是单线程的这可算是一个值得继续改进的地方。
比如有两个用户同时发出了请求这两个用户的连接正好分配在两个IO线程上那么两个请求的第1步操作可以在两个线程上并行执行然后汇总到第2步串行执行这样总的响应时间比完全串行执行要短一些在“读取并解析”所占的比重较大时效果更为明显。
例2多线程分担负载。假设我们要做一个求解Sudoku数独的服务这个服务程序在端口9981接受请求输入为一行81个数字待填数字用0表示输出为填好后的81个数字1~9如果无解输出“NO\r\n”。
由于输入格式很简单我们用单个线程做IO就行了。先假设每次求解的计算用时为10ms用前面的方法计算单线程程序能达到的吞吐量上限为100qps在8核机器上如果用线程池来做计算能达到的吞吐量上限为800qps。接下来我们看看多线程如何降低响应时间。
假设1个用户在极短的时间内发出了10个请求如果用单线程“来一个处理一个”的模型这些reqs会排在队列里依次处理这个队列是操作系统的TCP缓冲区不是程序里自己的任务队列。在不考虑网络延迟的情况下第1个请求的响应时间是10ms第2个请求要等第一个算完了才能获得CPU资源它等了10ms算了10ms响应时间是20ms依此类推第10个请求的响应时间为100ms这10个请求的平均响应时间为55ms。
如果Sudoku服务在每个请求到达时开始计时会发现每个请求都是10ms响应时间而从用户的观点来看10个请求的平均响应时间为55ms。这是因为我们计算请求响应时间时没有计算请求在TCP接收缓冲内的停留时间。
下面改用多线程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。
5.多线程程序如何让IO和计算相互重叠降低latency基本思路是把IO操作通常是写操作通过BlockingQueue交给别的线程去做自己不必等待。
例1日志logging。在多线程服务器程序中日志logging至关重要本例仅考虑写log file的情况不考虑log server。
在一次请求响应中可能要写多条日志消息而如果用同步的方式写文件fprintf或fwite函数多半会降低性能因为 1.文件操作一般比较慢服务线程会等在IO上让CPU闲置增加响应时间。
2.就算有buffer先将日志存起来用一次write代替多次write还是不灵。多个线程一起写为了不至于把buffer写错乱往往要加锁。这会让服务线程互相等待降低并发度。同时用多个log文件不是办法除非你有多个磁盘且保证log files分散在不同的磁盘上否则还是要受到磁盘IO的制约
解决办法是单独用一个logging线程负责写磁盘文件通过一个或多个BlockingQueue对外提供接口。别的线程要写日志时先把消息字符串准备好然后往queue里一塞就行基本不用等待。这样服务线程的计算就和logging线程的磁盘IO相互重叠降低了服务线程的响应时间。
尽管logging很重要但它不是程序的主要逻辑因此对程序的结构影响越小越好最好能简单到如同一条printf语句且不用担心其他性能开销。而一个好的多线程异步logging库能帮我们做到这点见第五章。Apache的log4cxx和log4j都支持AsncAppender这种异步logging方式
例2memcached客户端。假设我们用memcached来保存用户最后发帖时间那么每次响应用户发帖的请求时程序里要去设置一下memcached里的值。这一步如果用同步IO会增加延迟。
对于“设置一个值”这样的write-only idempotent操作幂等性写操作这种操作的幂等性保证了无论执行多少次写入结果都相同我们其实不用等memcached返回操作结果这里也不用在乎set操作失败那么可以借助多线程来降低响应延迟。比如我们可以写一个多线程版的memcached客户端对于set操作调用方只要把key和value准备好调用一下asyncSet()把数据往BlockingQueue上一放就能立即返回延迟很小。剩下的事就留给memcached客户端的线程去操心而服务线程不受阻碍。
其实所有的网络写操作都可以这么异步地做但这也有一个缺点就是每次asyncWrite()都要在线程间传递数据。其实如果TCP缓冲区是空的我们就可以在本线程写完不用劳烦专门的IO线程。Netty就使用了设个办法来进一步降低延迟。
以上仅讨论了“打一枪就跑”的情况如果是一问一答比如从memcached取一个值那么“重叠IO”并不能降低响应时间因为无论如何都要等memcached的回复。这是我们可以用别的方式来提高并发度见问题8虽不能降低响应时间但也不要浪费线程在空等上。
6.为什么第三方库往往要用自己的线程event loop模型没有标准实现。如果自己写代码尽可以按所用Reactor的推荐方式来编程。但第三方库不一定能很好地适应并融入这个event loop framwork有时需要用线程来做一些串并转换有些任务可能需要将串行操作例如轮询或阻塞等待与并行操作事件循环结合起来。比方说检测串口上的数据到达可以用文件描述符的可读事件因此可以方便地融入event loop。但是检测串口上的某些控制信号例如DCDData Carrier Detect数据载波检测它是一种串行通信中使用的信号通常在串口通信例如RS-232或RS-485中使用DCD信号指示着接收设备是否检测到了另一端的数据载波只能用轮询ioctl(fd, TIOCMGET, flags)它的作用是获取串口设备的控制信号状态或阻塞等待ioctl(fd, TIOCMIWAIT, TIOCM_CAR)等待串口设备上特定的控制信号状态发生变化其中 TIOCM_CAR 表示DCD信号要想融入event loop需要单独起一个线程来查询串口信号翻转在转换为文件描述符的读写事件可通过pipe函数。
对于Java这个问题还好办一些因为thread pool在Java里有标准实现叫ExecutorService。如果第三方库支持线程池那么它可以和主程序共享一个ExecutorService而不是自己创建一堆线程比如在初始化时传入主程序的obj。对于C情况麻烦地多Reactor和thread pool都没有标准库。
例1libmemcached只支持同步操作。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的客户端但数据库客户端就没那么幸运了。
例2MySQL的官方C API不支持异步操作。MySQL的官方客户端只支持同步操作对于UPDATE/INSERT/DELETE之类只要行为不管结果的操作如果代码需要得知其执行结果则另当别论我们可以用一个单独的线程来做以降低服务线程的延迟。可仿照前面memcached_set的例子。麻烦的是SELECT如果把它也异步化就得动用更复杂的模式了见问题8。
相比之下PostgreSQL的C客户端libpq的设计要好得多我们可以用PQsend-Query()来发起一次查询然后用标准的select/poll/epoll来等待PQsocket。如果有数据可读就用PQconsumuInput处理之并用PQisBusy判断查询结果是否已就绪。最后用PQgetResult来获取结果。借助这套异步API我们可以很容易地为libpq写一套wrapper使之融入程序所用的event loop模型中。
7.什么是线程池大小的阻抗匹配原则如果池中线程在执行任务时密集计算所占的时间比重为P001而系统一共有C个CPU为了让这C个CPU跑满而又不过载线程池大小的经验公式为TC/P。T是个hint考虑到P值的估计不是很准确T的最佳值可以上下浮动50%。这个经验公式的原理很简单T个线程每个线程占用P的CPU时间如果刚好占满C个CPU那么必有C×PC。下面验证一下边界条件的正确性。
假设C8P1.0线程池的任务完全是密集计算那么T8。只要8个活动线程就能让8个CPU饱和再多也没用因为CPU资源已经耗光了。
假设C8P0.5线程池的任务有一半是计算有一半等在IO上那么T16。考虑操作系统能灵活、合理地调度sleeping/writing/running线程那么大概16个“50%繁忙的线程”能让8个CPU忙个不停。启动更多线程并不能提高吞吐量反而因为增加上下文切换的开销而降低性能。
如果P0.2这个公式就不适用了T可以取一个固定值如5×C。另外公式里的C不一定是CPU总数可以是“分配给这项任务的CPU数目”比如在8核机器上分出4个核来做一项任务那么C4。
8.除了作者推荐的Reactorthredpool还有别的non-trivial多线程编程模型吗有Proactor。如果一次请求响应中要和别的进程打多次交道那么Proactor模型往往能做到更高的并发度。当然代价是代码变得支离破碎难以理解Proactor依赖于异步事件处理和回调机制它将程序的执行流分散到多个回调函数或事件处理程序中使代码更加分散可能会增加调试和维护的复杂性。
这里举HTTP proxy为例一次HTTP proxy的请求如果没有命中本地cache那么它多半会 1.解析域名不要小看这一步对于一个陌生的域名解析可能要花几秒的时间。
2.建立连接。
3.发送HTTP请求。
4.等待对方回应。
4.把结果返回给客户。
这5步中跟2个server发生了3此round-trip每次都可能花几百毫秒 1.向DNS问域名等待回复。
2.向对方的HTTP服务器发起连接等待TCP三路握手完成。
3.向对方发送HTTP request等待对方response。
而实际上HTTP proxy本身的运算量不大如果用线程池池中线程的数目会很庞大不利于操作系统的管理调度。
这时我们有两个解决思路 1.把“域名已解析”、“连接已建立”、“对方已完成响应”做成event继续按照Reactor的方式来编程。这样一来每次客户请求就不能用一个函数从头到尾执行完成而要分成多个阶段并且要管理好请求的状态目前到了第几步。
2.用回调函数让系统来把任务串起来。比如收到用户请求如果没有命中本地缓存那么需要执行 1立刻发起异步的DNS解析startDNSResolve()告诉系统在解析完之后调用DNSResolved()函数。
2在DNSResolved()中发起TCP连接请求告诉系统在连接建立之后调用connectionEstablished()。
3在connectionEstablished()中发送HTTP request告诉系统在收到响应后调用httpResponsed()。
4最后在httpResponsed()里把结果返回给客户。
.NET大量采用的BeginInvoke/EndInvoke操作也是这个编程模式。当然对于不熟悉这种编程方式的人代码会显得很难看。有关Proactor模式的例子可参看Boost.Asio的文档这里不再多说。
Proactor模式依赖操作系统或库来高效地调度这些子任务每个子任务都不会阻塞因此能用比较少的线程达到很高的IO并发度。
Proactor能提高吞吐但不能降低延迟所以作者没有深入研究。另外在没有语言只能支持的情况下Proactor模式让代码非常破碎在C中使用Proactor是很痛苦的。因此最好在“线程”很廉价的语言中使用这种方式这时runtime往往会屏蔽细节程序用单线程阻塞IO的方式来处理TCP连接。
9.模式2和模式3a该如何取舍模式2是一个多线程的进程模式3a是多个相同的单线程进程。
作者认为在其他条件相同的情况下可以根据工作集work set的大小来取舍。工作集是指服务程序响应一次请求所访问的内存大小。
如果工作集较大那么就用多线程避免CPU cache换入换出影响性能否则就用单线程多进程享受单线程编程的便利。举例来说 1如果程序有一个较大的本地cache用于缓存一些基础参考数据in-memory look-up table几乎每次请求都会访问cache那么多线程更适合一些因为可以避免每个进程都自己保留一份cache增加内存使用。这一点可以用共享内存来解决如果这些基础参考数据只是读取而不修改的话
2memcached这个内存消耗大户用多线程服务端就比在同一台机器上运行多个memcached instance要好。但如果你在16GiB内存的机器上运行32-bit memcached那么此时多instance是必需的
3求解Sudoku用不了多大内存。如果是单线程编程更方便的话可以用单线程多进程来做。再在前面加一个单线程的load balancer仿lighttpdfastcgi的成例。
线程不能减少工作量即不能减少CPU时间。如果解决一个问题需要执行一亿条指令这个数字不大那么用多线程只会让这个数字增加。但是通过合理调配这一亿条指令在多个核上的执行情况我们能让工期提早结束。这听上去像统筹方法指在管理、规划或决策中采用综合性的方法来考虑多个因素或利益相关者的需求和目标这种方法旨在通过协调和整合各种因素以达到整体的最佳效果或目标其实也确实是统筹方法。