高并发服务器架构

    设计一个稳定,高效的服务器,需要考虑很多的方面,不管是系统方面的,还是代码方面的。那么究竟有哪些?
    一: 导致服务器低效的四个罪魁祸首:数据拷贝,(用户态/内核态切换)上下文切换,内存管理,锁竞争;
    数据拷贝
        ---首先提下零拷贝。零拷贝( zero-copy ),某种程度上来说可以有效的改善数据传输的性能,在内核驱动程序(比如网络堆栈或者磁盘存储驱动程序)处理 I/O 数据的时候,零拷贝技术可以在某种程度上减少甚至完全避免不必要 CPU 数据拷贝操作。零拷贝已经是比较成熟的技术,目前有很多高性能的网络I/O框架已经实现了这一技术,如netmap.
        ---尽管所有人都知道数据拷贝是不好的,但理解上还是有些细微的差别,最重要的是,数据拷贝通常是隐藏在调用驱动或者其他的库代码的时候,你能确定没有进行数据拷贝操作?也许这比你想象的要多.
        ---避免数据拷贝的真正有用的方法是使用间接的,传递buff描述符(或者buff链表描述符)来取代大量的指针,每个描述符包含以下几个方面:
        (1)指buff针以及整个buff的长度;
        (2)指针和长度,或者指针偏移地址和长度(一般是buff已经部分被填充);
        (3)buff链表的prev,next;
        (4)引用计数;
        ---与其拷贝内存中的数据,不如在正确的描述符上简单的递增引用计数,在某些条件下,它工作的很好,如典型的网络协议栈操作,但也有可能他会令你头痛。通常来说,在buff链表的头尾添加新的buff,为整块buff添加引用计数,释放整块buff是比较容易的操作;而在中间添加新的buff,一块一块的释放buff,或者引用部分buff是很困难的;如果让你尝试切割或者组合buff能让你发疯。
        ---并不推荐你在任何地方使用这种方法,为什么呢?因为每次查找buff时,你需要从链表头部遍历,直到找到那个为止,比起数据拷贝,它的开销更大。我认为更好的方法是分配一个足够大的对象,比如说内存块,无需数据拷贝,他实现起来要容易的多(如环形缓存);
        ---不要刻意的避免数据拷贝,很多代码因刻意去避免,使得效率更低,如强制上下文切换,中断一个大的I/O操作,数据拷贝是昂贵的,你需要梳理你的代码,去除那些耗时却只涉及少量的数据拷贝的代码,这个比其他方法要好的多。
        上下文切换
        ---鉴于每个人都理所当然的任务数据拷贝是非常耗性能的,我非常奇怪有多少人会忽略上下文切换给性能带来的影响。根据以往的经验来看,相对于数据拷贝来说,在高负载的时候过多的上下文切换更是灾难性的,系统需要花费更多的时间从一个线程切换到另外一个线程,而且这个时间比实际工作的时间还要多。更疯狂的是,在某种程度上来说,导致上线问切换的原因是显而易见的。其中之一是过多的活动线程在你的处理器上,活动线程越多,上下文切换的更厉害,如果你足够幸运,上下文切换次数成直线上升,而大部分的情况是成指数上升的。这样就很容易解释为什么多线程设计成为每一个连接分配一个线程(mysql 应该算是比较典型)。现实中比较明智的选择是限制系统中活动线程的数量,通常来说,小于或者等于你处理器的数量。一种比较常用的方法是只使用一个线程,它既避免了上线文切换,而且无需加锁,但是它无法重复利用多处理器的优势,除非你的程序是非CPU密集型。
        ---首先需要解决的是:“线程节约型”程序一个线程是怎样同时处理多个连接,通常前端用sleect/poll,asynchronous I/O,signals or completion port,这些一般都是基于事件驱动的。至于使用哪些前端的API是最好的,C10K paper也许可以帮助到你。以我个人来说,我认为select/poll 以及singals都是些丑陋的伎俩(或许那时候还没有出现epoll吧),我比较倾向的是使用AIO,completion port。使用并不重要,如果他们喜欢使用sleect(),只要它在程序中工作的很好,都可以理解。不要太纠结于你程序前端的最外层做了什么。
        ---多线程事件驱动服务器是最简单的概念模型,中心有一个队列,所有的请求被一个或者多个监听线程(类似线程池)读取并放入队列中,然后通过一个或者多个工作线程移除,处理。概念上来说,这是一个不错的模型。但是所有人都频繁的使用这个模型实现他们的代码。为什么会错?因为在工作线程中从一个线程切换到另外一个线程会产生上线文切换。有些人甚至错上加错的将请求的回复通过原始线程发送-这样导致每个请求产生两次上下文切换。给定一个线程从监听者到工作者又回到监听者的过程中而不改变上下文,使用这种对称的方法是很重要的。是否涉及线程间分开连接或者所有线程作为监听者是否轮流工作对于整组连接就显得不是那么重要了。
        ---通常来说,即使在下一时刻很难知道当前有多少个线程将会被激活,毕竟,请求能在任一时刻,通过任一连接发过来,或者各种守护任务的后台专属线程能在某个时刻处理并唤醒它。如果你不知道当前有多少活动线程,你如何去限制活动线程个数? 以我的以经验,最有效的方法也是最简单的办法:使用老式的计数信号,每个线程都会持有它本身真正的工作。如果已经达到线程个数上限,每一个监听模式的线程当被唤醒并阻塞信号,此时会触发一次额外上下文切换,一旦所有监听模式的线程已经以这样的方式阻塞信号,它们就不会继续再竞争资源,直到其中有一个线程释放信号。它对系统的影响是微不足道的,更重要的是,这个维护线程处理的方法在大部分时间都是休眠的,对当前活动线程影响比较小。这个方法比其他的可选的方法要好的多。(按我的理解,可能是通过计数的方式才统计当前的活动线程数量吧,早期也确实这么做过,创建一个守护线程,根据CPU的负载情况来调节线程个数)。
        ---一旦请求被分解为2个阶段(监听,工作),可能会有多个线程为其服务。自然而然的,这个处理可能会进一步分解为不止两个阶段。在最简单的形式中,对请求的处理,在一个方向上持续的调用一个阶段,并且不断的重复,从而成为了一个问题。然而,更复杂的是,一个阶段可能会表现出两个处理路径,他们分别调用下一个不同的阶段,或者生成一个回复而不调用下一个阶段。因此,每个阶段需要为请求指定下一步会发生什么。以下有三种可能性,通过各个阶段的返回值来表现:
        (1)请求需要过渡到下一个阶段,返回ID或者指针;
        (2)请求已完成,返回一个指定的完成代码;
        (3)请求被阻塞,其等价于上一个情况,只是这个请求未被释放,并且发送到其他线程等待下一步处理,类似于线程间通信。
        需要注意的是,在这个模型中,查询请求是在阶段内完成的,而不是在阶段间,这样可以避免常见的诟病:不断的将请求放入到一个后续极端队列中,然后立即调用后续阶段并将请求出列,我称此为大量的活动队列-死锁。
        内存管理:
        ---内存分配和释放在大多数应用程序中是很平常的操作。因此,许多聪明的技巧已经应用到一些通用高效的内存分配器中。不管怎样,聪明的技巧可以弥补分配器的一般性必然比大多数可选的内存分配器低效这个事实。因此我有三个避免完全使用系统内存分配器的建议:
        (1)简单的预分配;我们知道在程序功能上人为的限制静态分配是不好的,但这却有许多其他的看起来不错的预分配形式。通常这归结于一个事实,通过系统内存分配也许会比少数,甚至是当一些内存‘浪费’在进程中情况要好。因此可以断言,可能只是N项会使用一次,因此在程序启动时预分配是一个可选的选择。即便不是那种情况,开始时预分配好一切也比在使用时分配要好,除非使用系统内存分配器一次连续的分配多块内存,这样大大的简化了错误恢复的代码。如果内存紧张,那么预分配不是一个很好的选择,但在大多数极端的情况下也是被证明是可行的。
        (2)针对频繁分配和释放的对象使用空闲列表,其基本的原理是把最近使用过的对象放入列表而不是释放它,如果希望再次使用的时候,可从列表中取出而不是重新从系统中分配一块内存。另外的好处是,从空闲列表中取出和放回还省去了构造/析构的调用开销。
        (3)事实上需要加锁处理,如果是在多线程程序中。我们可以为每个线程申请一个私有的空闲列表,那么这个列表只会有一个线程访问,无需加锁了。
        锁竞争:
        ---高效的锁定方案设计是出了名的难,因为我把它比作为“奥德赛”的两个怪物物后斯库拉和卡律布狄斯。斯库拉是过于简单化以及粒度比较粗的,序列化,可并行的操作,并因此牺牲性能和可扩展性;相反,卡律布狄斯是过于复杂以及粒度比较细,锁操作时间和空间再次削弱程序性能。靠近斯库拉是死锁和活锁条件的浅滩;靠近卡律布狄斯处是竞太条件的浅滩。这两者之间有一个狭窄的通道,代表既高效有正确的正确的方式,真的存在么?由于锁跟程序本身有这密切的关系,它往往不能从根本上改变程序是如何工作的,不可能设计一个良好的解决方案。这也是为何人们讨厌锁,而且试图给自己理由去使用单线程方法的原因。如果更高效?原文中给了一个方法,大概就是也是通过某种方法找到代码和性能的平衡点。
        此文只是在我现有的水平下对原文的一些翻译理解,如有误导,请见谅!
        参考: 
展开阅读全文

没有更多推荐了,返回首页