当前位置: 首页 > Linux

Linux高性能服务器设计_1

时间:2023-04-07 01:28:46 Linux

C10K、C10M计算机领域的很多技术都是需求驱动的。20世纪90年代,由于互联网的飞速发展,网络服务器已经无法支撑快速增长的用户数量。1999年,DanKegel提出了著名的C10问题:在一台服务器上同时处理10,000个客户端网络连接。10000个网络连接不向服务器发送请求,部分连接不活跃,同时只有极少数连接发送请求。对于不同的服务类型,每个连接发送请求的频率是不同的。游戏服务器的连接会频繁发送请求,而网页服务器的连接发送请求的频率要低很多。无论如何,根据经验,对于特定的服务类型,连接越多,同时发送请求的连接就越多。今天,C10K的问题当然已经解决了。不仅如此,一台机器可以支持越来越多的连接。后来又提出了C10M问题,支持一台机器1000万个连接。2015年,MigratoryData单机承载12M连接。连接,解决了C10M问题。本文首先回顾了C10问题的解决方案,然后讨论了如何构建支持C10M的应用程序,并讨论了涉及的各种技术。解决C10K问题的时间要追溯到1999年,当时要实现一个web服务器。大概有几种模式。简单进程/线程模型这是一种非常简单的模式。网络连接建立后,accept返回一个新的连接,服务器启动一个新的进程/线程专用于这个连接。在性能和可扩展性方面,这种模式非常糟糕。原因是进程/线程创建和销毁的时间。操作系统显然需要时间来创建进程/线程。在繁忙的服务器上,如果每一秒都有大量的连接建立和断开。每个进程/线程处理一个客户端连接模式。每个新连接都必须创建一个进程/线程。当连接断开时,相应的线程/进程被销毁。创建和销毁进程/线程的操作会消耗大量的CPU资源。使用进程池和线程池可以缓解这个问题。内存使用情况。主要包括两个方面,一个是内核数据结构占用的内存空间,一个是Stack占用的内存。有些应用的调用栈非常深,比如Java应用,经常可以看到几十层甚至上百层的调用栈。上下文切换开销。在上下文切换时,操作系统的调度器会中断当前线程,并选择另一个可运行的线程继续在CPU上运行。调度器需要保存当前线程的上下文信息,然后选择一个可运行的线程,再将新线程的状态恢复到寄存器中。保存和恢复场景所需的时间与CPU型号有关。选择一个可运行的线程完全是一个软件操作。Linux2.6才开始使用常量时间调度算法。以上就是上下文切换的直接开销。此外,还有一些间接费用。上下文切换导致相关的缓存失效,如L1/L2Cache、TLB等,也会影响程序的性能,但间接开销很难衡量。有趣的是,虽然这种模式的性能极差,但它仍然是我们今天看到的最常见的模式,很多网页程序都是以这种方式运行的。select/poll的另一种方法是使用select/poll在一个线程中处理多个客户端连接。select和poll可以监视多个套接字文件描述符。当文件描述符就绪时,select/soll从阻塞状态返回,通知应用程序可以处理用户连接。使用这种方式,我们只需要一个线程就可以处理大量的连接,避免了多进程/线程的开销。之所以把select和poll放在一起,是因为两者很相似,性能上基本没有区别。唯一不同的是poll突破了select的1024个文件描述符的限制。但是,当文件描述符数量增加时,poll的性能会急剧下降,所以所谓突破1024个文件描述符其实是没有意义的。select/poll并不完美,还有很多问题:每次调用select/poll时,都必须将文件描述符集合从用户地址空间复制到内核地址空间,select/poll返回后,调用者必须遍历allfiledescriptionscharacter一个一个判断文件描述符是否可读/可写。这两个限制使得select/poll完全失去了可扩展性。连接越多,文件描述符越多,文件描述符越多,每次调用select/poll带来的从用户空间到内核空间的复制开销就越大。最严重的是,当消息到达,select/poll返回时,必须遍历所有的文件描述符。假设现在有10000个连接,只有一个发送请求,但是select/poll会检查所有10000个连接。epollFreeBSD4.1引入了kqueue,那是2000年7月。在Linux上,2年后的2002年引入了kqueue的类似实现:epoll。epoll最早进入Linux内核主线是在2.5.44,这已经是2002年,也就是C10K问题提出的三年后。epoll是如何提供高性能和可扩展的IO多路复用机制的呢?首先,epoll引入了epoll实例的概念,它与内核中要监控的一组文件描述符配置相关联:interestlist。这样做的好处是每次要添加一个需要监控的文件描述符时,不需要把所有的文件描述符都配置一次,然后再从用户地址空间复制到内核地址空间。只将单个文件描述符复制到内核地址空间,复制开销从O(n)降低到O(1)。注册文件描述符后,调用epoll_wait开始等待文件描述符事件。epoll_wait只能返回就绪的文件描述符。因此,epoll_wait返回后,程序只需要处理真正需要处理的文件描述符,而不用遍历所有的文件描述符。假设所有N个文件描述符中,只有一个文件描述符Ready,select/poll需要执行N个周期,而epoll只需要执行一次。epoll出现后,Linux上才真正有了可扩展的IO多路复用机制。基于epoll,可以支持的网络连接数取决于硬件资源的配置,不再受??内核实现机制的限制。CPU越强,内存越大,可以支持的连接数也越多。编程模型Reactor和proactor不同的操作系统提供了不同的IO多路复用实现,例如Linux上的epoll,FreeBSD上的kqueue,Windows上的IOCP。对于需要跨平台的程序,需要一个抽象层来提供统一的IO复用接口,屏蔽各个系统接口的差异。Reactor就是实现这一目标的一次尝试,最早出现在DouglasC.Schmidt的论文《TheReactorAnObject-OrientedWrapperforEvent-DrivenPortMonitoringandServiceDemultiplexing》中。从论文名称可以看出,Reactor是对poll编程模型的面向对象封装。考虑到论文的时间,正是面向对象概念火热的时候,凡事都要流行面向对象。在论文中,DCSchmidt描述了为什么需要这样一个Wrapper,并给出了以下原因:操作系统提供的接口过于复杂且容易出错。select和poll都是通用接口,因为通用,增加了学习和正确使用的复杂度。接口抽象层次太低,涉及太多底层细节。不可跨平台移植。难以扩展。其实除了第三个跨平台,其他几个原因真的很难站得住脚。select/poll等接口是否复杂,使用中是否容易出错,编写的程序难以扩展?但是你不说怎么体现Reactor的价值。正如论文名所说,Reactor本质上是对操作系统的IO多路复用机制的面向对象的封装。为了证明Reactor的价值,DCSchmidt还实现了一个具有C++面向对象特性的编程框架:ACE,实际上使用ACE要比直接使用poll或者epoll复杂的多。后来DCSchmidt写了一本书《面向模式的软件架构》,又提到了Reactor,并改名为ReactorPattern。现在网上能查到的Reactor资料基本上都是基于ReactorPattern的,而不是早期的Object-OrientendWrapper。.《面向模式的软件》架构中还提到了另一种模式,称为Proactor,它与Reactor非常相似。Reactor是针对同步IO的,Proactor是针对异步IO的。Callback、Future和FiberReactor看起来并不复杂,但是当你想写一个完整的应用时,你会发现并没有那么简单。为了避免阻塞Reactor的主逻辑,所有可能造成阻塞的操作都必须注册到epoll上。问题是处理逻辑碎片化,大量使用回调,导致代码复杂难懂。如果应用程序中有非网络IO阻塞操作,问题就更严重了,比如在程序中读写文件。Linux中的文件系统操作被阻止。虽然也有LinuxAIO,但是一直不够成熟,用起来比较尴尬。许多软件使用线程池来解决这个问题。epoll解决不了的阻塞操作,丢进线程池执行。这反过来会产生多线程内存开销和上下文切换的问题。Future机制是对Callback的简单优化。本质上还是Callback,只是提供了一致的接口。代码比较简单,但是实际使用起来还是比较复杂的。Seastar是一个非常全面的未来风格框架。从它的代码中我们可以看出这种编程风格确实很复杂。在blocking编程中,一个功能几行代码就可以完成,但是在Seastar中需要几百行代码,几十个labmda(在Seastar中称为continuation)。Fiber是用户态调度的线程,例如Go语言中的goroutine。有些人可能会把这种机制称为协程,但我认为协程和纤程之间有很大的区别。协程是一种广义的子进程,有多个入口和出口,用于一些相互协作的程序,典型的例子就是Python中的生成器。Fiber是一种运行和调度机制。光纤真正实现了高性能和易用性。在Go语言中,使用goroutine实现一个高性能的服务器是一件轻松愉快的事情,不用考虑线程数、epoll、回调等复杂的操作,和编写阻塞的程序完全一样。网络优化Kernelbypass网络子系统是Linux内核中非常庞大的一个组件,它提供了各种通用的网络能力。通用通常意味着它在某些场景下不是最好的选择。事实上,业界的共识是Linux内核网络不支持大并发网络能力。根据我以往的经验,Linux最多只能处理1MPPS,而现在的10Gbps网卡通常可以处理10MPPS。随着更高性能的25Gbps和40Gbps网卡的出现,Linux内核的网络能力越来越捉襟见肘。为什么Linux不能充分发挥网卡的处理能力呢?原因是大部分网卡的发送和接收都是采用中断方式,每次中断的处理时间在100us左右。此外,还必须考虑缓存未命中造成的开销。有些网卡使用NAPI,轮询+中断处理数据包,当数据包放入队列时,仍然需要触发软中断。数据从内核地址空间复制到用户地址空间。有发送和接收数据包的系统调用。从网卡到申请流程的环节太长,包含很多不必要的操作。Linux高性能网络的一个方向是绕过内核的网络栈(kernelbypass),业界也有很多尝试。PF_RING高效抓包技术,性能优于libpcap。需要自己安装内核模块,启用ZCDriver,并设置transparent_mode=2,消息会绕过内核网络栈,直接传递给客户端程序。Snabbswitch是一个用Lua编写的网络框架。完全接管网卡,使用UIO(UserspaceIO)技术实现用户态的网卡驱动。IntelDPDK直接在用户模式下处理数据包。非常成熟,性能强大,局限是只能用在Intel网卡上。根据DPDK数据,在3GHzCPUCore上,每条消息的平均处理时间仅为60ns(一次内存访问时间)。Netmap是一个用于发送和接收原始数据包的高性能框架。它包括内核模块和用户态库函数,需要网卡驱动的配合。因此目前只支持几种特定的网卡类型,用户也可以自行修改网卡驱动。XDP使用LinuxeBPF机制将消息处理逻辑委托给网卡驱动。一般用于包过滤和转发场景。内核旁路技术最大的问题是不支持POSIX接口,用户无法在不修改代码的情况下直接迁移到内核旁路技术。对于大多数程序来说,还需要运行在标准的内核网络栈上,通过调整内核参数来提高网络性能。网卡多队列报文到达网卡后,触发CPU中断,CPU执行网卡驱动从网卡硬件缓冲区中读取报文内容,放入CPU接收队列解析后。这里的所有操作都是在特定的CPU上执行的。在高性能场景下,单个CPU无法处理所有数据包。对于支持多队列的网卡,可以将数据包分发到多个队列,每个队列对应一个CPU进行处理,解决了单CPU处理的瓶颈。为了充分发挥多队列网卡的价值,我们还要做一些额外的设置:将每个队列的中断号绑定到特定的CPU上。这样做的目的是一方面保证网卡中断的负载可以分配到不同的CPU上,另一方面可以区分负责网卡中断的CPU和负责网卡中断的CPU应用程序,以避免相互干扰。在Linux中,每个队列的中断号保存在/sys/class/net/${interface}/device/msi_irqs下。有了中断号后,我们就可以设置中断与CPU的对应关系了。网上有很多文章可以参考。网卡Offloading回忆一下TCP数据的发送过程:应用程序将数据写入socketbuffer,内核将buffer数据分成不大于MSS的碎片,附加TCPHeader和IPHeader,计算Checksum,然后推送数据到NIC发送队列。这个过程需要CPU全程参与。随着网卡的速度越来越快,CPU逐渐成为瓶颈,CPU处理数据的速度跟不上网卡发送数据的速度。根据经验,发送或接收1bit/sTCP数据需要1HzCPU,1Gbps需要1GHzCPU,10Gbps需要10GHzCPU,这远远超出了单核CPU的能力。即使可以充分利用多核,假设单CPUCore为2.5GHz,仍需要4个CPUCore。为了优化性能,现代网卡在硬件层面集成了TCP分段、添加IP头、计算校验和等功能。这些操作不再需要CPU参与。这个功能叫做tcpsegmentoffloading,简称tso。使用ethtool-k查看网卡是否开启。除了tso,还有其他的offloading,比如支持udp分片的ufo,不依赖驱动的gso,优化接收链路充分利用多核的lro。随着摩尔定律的失效,CPU已经从追求高主频转向追求更多核心,目前的服务器大多拥有96核心甚至更高。要构建支持C10M的应用程序,必须充分利用所有CPU。最重要的是程序必须具备横向扩展的能力:随着CPU数量的增加,程序可以支持更多的连接。很多人有一个误区,认为使用多线程就可以在程序中使用多核。考虑到CPython程序,可以创建多个线程,但是由于GIL的存在,程序最多只能使用一个CPU。其实多线程和并行是不同的概念。多线程是指程序中的多个任务同时执行。每个线程中的任务可以完全不同。线程数与CPU核心数没有直接关系。它可以在单核机器上运行。数百个线程。并行就是充分利用计算资源,将一个大的任务拆解成小规模的任务,分配给各个CPU运行。并行性可以通过多线程来实现。如果系统上有几个CPU,则启动几个线程,每个线程完成一部分任务。并行编程的难点在于如何正确处理共享资源。并发访问共享资源最简单的方法是加锁。但是,使用锁会带来性能问题。获取和释放锁会产生性能开销。被锁保护的临界区代码不能像CPython的GIL那样只能顺序执行。未充分利用的CPU。ThreadLocal和Per-CPU变量的思路是一样的。它们都创建变量的多个副本。使用变量时,只访问本地副本,因此不需要同步。现代编程语言基本都支持ThreadLocal,使用起来非常简单。在C/C++中,也可以使用__thread标签来声明ThreadLocal变量。每个CPU取决于操作系统。当我们提到Per-CPU时,通常指的是LinuxPer-CPU机制。Per-CPU变量在Linux内核代码中被广泛使用,但在应用程序代码中并不常见。如果应用程序中的工作线程数与CPU数相等,并且每个线程都固定到一个CPU上,此时就可以使用了。原子变量如果共享资源是int等简单类型,访问方式比较简单,此时可以使用原子变量。原子变量比使用锁执行得更好。在竞争不太激烈的情况下,原子变量的操作性能与加锁基本相同。但是,当并发比较激烈的时候,等待锁的线程就必须进入等待队列,等待重新调度。这里的挂起和重调度过程需要进行上下文切换,浪费了更多的时间。大多数编程语言都提供了基本变量对应的原子类型,一般会提供set、get、compareAndSet等操作。lock-freelock-free的概念来自于一种算法,称为non-blockingiffailureorsuspendedofany线程不能导致另一个线程失败或挂起;如果在每个步骤中,某个线程都可以取得进展,则该算法称为无锁算法。非阻塞算法中任何一个线程的失败或挂起都不会引起其他线程的失败或挂起。无锁进一步保证了线程之间没有依赖。这个表述比较抽象。具体来说,非阻塞要求没有互斥。在互斥的情况下,线程在进入临界区之前必须先获取锁。如果当前持有锁的线程被挂起,等待锁的线程肯定要一直等下去。对于livelock或者starvation场景,当一个线程挂掉或者hang时,其他线程不仅可以正常运行,还可能解决livelock和starvation的问题,所以livelock和starvation符合非阻塞,而不是无锁。实现无锁数据结构并不容易。幸运的是,目前已经有几种常见数据结构的无锁实现:buffer、list、stack、queue、map、deque,我们可以直接使用。优化锁的使用有时候没有条件使用lock-free,必须使用锁。针对这种情况,还是有一些优化方法的。首先,最小化临界区的大小并使用细粒度的锁。锁粒度越细,并行执行的效果越好。其次,选择合适的锁,比如考虑选择读写锁。CPU亲和性利用CPU亲和性机制合理规划线程与CPU之间的绑定关系。前面提到,CPU亲和性机制用于将多队列网卡的中断处理分配给多个CPU。不仅是中断处理,线程也可以绑定。绑定后,线程只会在绑定的CPU上运行。为什么要将线程绑定到CPU上?绑定CPU有几个好处:为线程预留CPU,保证线程有足够的资源运行,提高CPU缓存的命中率,一些对缓存敏感的线程必须绑定CPU。更精细的资源控制。可能需要提前静态划分每个工作线程的资源,比如给每个请求处理线程分配一个CPU,其他后台线程共享一个CPU,工作线程和中断处理程序工作在不同的CPU上。在NUMA架构中,每个CPU都有自己的内存控制器和内存插槽,CPU访问本地内存和访问远程内存的速度大约快3倍。使用affinity将线程绑定到CPU上,相关数据也分配到CPU对应的本地内存中。在Linux上设置CPUaffinity非常简单。可以使用命令行工具taskset,也可以直接在程序中调用APIsched_getaffinity和sched_setaffinity。其他优化技术使用Hugepage。在Linux中,程序中使用的内存地址是虚拟地址,而不是内存的物理地址。.为了简化虚拟地址到物理地址的映射,虚拟地址到物理地址映射的最小单位是“页”。默认情况下,每页的大小为4KB。CPU指令中出现的虚拟地址,为了读取内存中的数据,必须在指令执行前将虚拟地址转换为内存的物理地址。Linux为每个进程维护一个虚拟地址到物理地址的映射表。CPU首先查表找到虚拟地址对应的物理地址,然后执行指令。由于映射表是在内存中维护的,CPU需要访问内存才能查表。与CPU的速度相比,内存其实是相当慢的。一般来说,CPUL1Cache的访问速度在1ns左右,访问一次内存需要60-100ns,比CPU执行一条指令要慢很多。如果每条指令都需要访问内存,例如,CPU的速度会严重降低。为了解决这个问题,CPU引入了TLB(translationlookasidebuffer),一种高性能缓存,缓存映射表中的一些条目。转换地址时,先从TLB中查找,找不到再去读内存。显然,最理想的情况是映射表可以完全缓存在TLB中,地址转换完全不需要访问内存。为了减小映射表的大小,我们可以使用“HugePages”:大于4KB的内存页。默认的HugePages是2MB,最大可以是1GB。避免动态分配内存内存分配是一项复杂且耗时的操作,涉及空闲内存管理、分配策略权衡(分配效率、碎片),尤其是在并发环境下,还要保证内存分配的线程安全。如果内存分配成为应用程序瓶颈,您可以尝试一些优化策略。比如内存复用i:不重复分配内存,而是复用已经分配的内存。在C++/Java中,考虑重用现有对象。这项技术在Java中尤为重要。它不仅可以降低创建对象的成本,还可以避免创建大量对象带来的GC开销。还有一个trick就是预分配内存,其实相当于在应用中实现了一套简单的内存管理,比如Memcached的Slab。零拷贝对于Web服务器来说,响应一个静态文件请求需要先将文件从磁盘读取到内存中,然后再发送给客户端。如果你自信地分析这个过程,你会发现数据首先从磁盘读取到内核的页面缓冲区,然后从页面缓冲区复制到Web服务器缓冲区,然后从Web服务器缓冲区发送到TCP发送缓冲区,最后通过网卡发送出去。在这个过程中,数据先从内核拷贝到进程,再从进程返回给内核。这两个副本是完全多余的。零拷贝是针对类似情况的优化方案。数据直接在内核中处理,无需额外复制。Linux中提供了几个ZeroCopy相关的技术,包括sendfile、splice、copy_file_range,sendfile常用于Web服务器以优化性能。最后但同样重要的是:不要过早优化。优化前考虑两个问题:当前性能是否满足要求,如果真的要优化,瓶颈找到了吗?在回答清楚这两个问题之前,不要盲目地去做。本文作者:太公阅读原文。本文为云栖社区原创内容,未经许可不得转载。