高并发可以说是近几年的一个热词,尤其是在互联网圈。高并发有这么邪恶吗?几千万的并发,动辄上亿的流量,听起来真的很恐怖。但是仔细想想,这么大的并发量和流量不都是通过路由器过来的吗?所有来自网卡的高并发流量都通过一个低调的路由器进入我们的系统。第一关是网卡。网卡如何抗高并发?这个问题根本不存在。从网卡的角度来看,几千万并发都一样,都是电信号。在网卡眼里,分不清你是千万级并发还是洪流。所以,衡量网卡好坏的时候,都是看带宽的,从来没有关于并发量的说法。网卡位于物理层和链路层,最后将数据传输到网络层(IP层)。有了网络层的IP地址,就已经可以识别出你是并发了。所以搞网络层的可以自豪地说我解决了高并发问题,可以出来吹牛了。谁与网络层无关?主角是路由器,主要是玩玩网络层。糊涂的和非专业的我们一般把网络层(IP层)和传输层(TCP层)放在一起,由操作系统提供,对我们透明,很低调,也很靠谱,这样我们都把它忽略了。吹牛是从应用层开始的,应用层的一切都源于Sockets。那些千万级的并发,最终通过传输层变成了千万级的Socket。那些吹牛说的就是如何快速处理这些Sockets。处理IP层数据和处理Socket有什么区别?没有连接,就没有等待。最重要的区别是IP层不是面向连接的,而Socket是面向连接的。IP层没有连接的概念。在IP层,每一个过来的数据包都会被处理,不需要向前看也不需要向后看。和Socket打交道,既要向前看,也要向后看。套接字是面向连接和上下文的。当你读到一句我爱你,你会激动很久。不回头看,心潮澎湃。要想看清前后,记忆起来会占用更多的内存,等待的时间也会更长;要隔离不同的连接,您必须分配不同的线程(或协程)。这些都解决了,看来还是有点难度。感谢操作系统,操作系统是个好东西。在Linux系统上,所有的IO都被抽象成文件,网络IO也不例外,被抽象成Sockets。但是Socket不仅仅是IO的抽象,它还抽象了如何处理Socket,最重要的就是select和epoll。众所周知的Nginx、Netty、Redis都是基于epoll的。这三个家伙基本上是千万级并发领域的必备技能。但是在很多年前,Linux只提供了select,可以处理极小的并发量,而epoll是为高并发而设计的,这得益于操作系统。但是,操作系统并没有解决高并发的所有问题。它只是让数据从网卡快速流向我们的应用程序。如何应对是个大问题。操作系统的使命之一就是最大限度地发挥硬件的能力,解决高并发问题。这也是最直接有效的解决方案,其次是分布式计算。我们前面提到的Nginx、Netty、Redis,都是将硬件能力发挥到极致的例子。我们如何才能最大限度地发挥硬件能力?核心矛盾要将硬件能力发挥到极致,首先要找到核心矛盾。在我看来,这个核心矛盾从计算机出现到现在几乎没有变过,就是CPU和IO的矛盾。CPU正以摩尔定律的速度发展,而IO设备(磁盘、网卡)则乏善可陈。慢IO设备成为性能瓶颈,必然导致CPU利用率低下,所以提高CPU利用率几乎就是充分利用硬件能力的代名词。中断与缓存CPU和IO设备的配合基本都是以中断的形式进行的,比如读磁盘的操作,CPU只是向磁盘驱动发送一条读磁盘到内存的指令,然后立即返回。这时候CPU就可以继续做其他事情了。读取磁盘到内存本身就是一个耗时的工作。磁盘驱动程序执行完指令后,会向CPU发送中断请求,告诉CPU任务已经完成。CPU处理中断请求。这时候就可以直接操作读入内存的CPUData了。中断机制可以让CPU以最小的代价处理IO问题,那么如何提高设备的利用率呢?答案是缓存。操作系统内部维护着IO设备数据的缓存,包括读缓存和写缓存。读取缓存很容易理解。我们经常在应用层使用缓存来尽可能避免读取IO。writecache应用层用的不多,操作系统的writecache完全是为了提高IO写入的效率。操作系统在写入IO时会合并并调度缓存,比如会使用电梯调度算法写入磁盘。高效使用网卡高并发问题首先要解决的是如何高效使用网卡。和磁盘一样,网卡内部也有缓存。网卡接收到网络数据,首先将其存放在网卡缓存中,然后写入操作系统的内核空间(内存)。我们的应用程序读取内存中的数据,然后进行处理。除了网卡的缓存外,TCP/IP协议还有发送缓冲区和接收缓冲区,以及SYN积压队列和接受积压队列。这些缓存如果配置不当,可能会导致各种问题。比如在TCP连接建立阶段,如果并发量太大,Nginx中Socket的backlog设置的值太小,就会导致大量的连接请求失败。如果网卡的缓存太小,当缓存满时,网卡会直接丢弃新收到的数据,造成丢包。当然,如果我们的应用读取网络IO数据的效率不高,会加速网卡缓存数据的积累。如何高效读取网络数据?目前,epoll在Linux上被广泛使用。操作系统将IO设备抽象成一个文件,网络抽象成一个Socket,而Socket本身也是一个文件,所以可以使用read/write方式来读写网络数据。在高并发场景下,如何高效地使用Socket快速读取和发送网络数据?要想高效地使用IO,就必须了解操作系统层面的IO模型。经典书籍《UNIX网络编程》中总结了五种IO模型,分别是:BlockingIONon-blockingIOMultiplexingIOSignal-drivenIOAsynchronousIOblockingIO我们以读操作为例。当我们调用read方法读取Socket上的数据时,如果此时Socket读缓存为空(没有数据从Socket的另一端发送过来),操作系统就会发送调用read方法的线程为挂起,直到Socket读缓存中有数据,操作系统再次唤醒该线程。当然,在唤醒的同时,read方法也有返回数据。我理解所谓阻塞就是操作系统会不会挂起线程。非阻塞IO对于非阻塞IO,如果Socket的读缓存为空,操作系统不会挂起调用read方法的线程,而是立即返回一个EAGAIN错误码。这种情况,可以轮询read方法,直到Socket的读缓存有数据,就可以读取数据了。这种方式的缺点非常明显,就是非常消耗CPU。多路复用IO对于阻塞IO,由于操作系统会挂起调用线程,如果要同时处理多个Socket,就必须相应地创建多个线程。线程会消耗内存,增加线程切换操作系统的负载,所以这种模式不适合高并发场景。有没有办法减少线程数?Non-blockingIO似乎可以解决问题。在一个线程中轮询多个Sockets,看似解决了线程数的问题,但实际上这种解决方法是无效的。原因是调用read方法是系统调用,系统调用是通过软中断实现的,会导致用户态和内核态的切换,所以很慢。但是这个想法是对的,有没有办法避免系统调用呢?没错,就是多路复用IO。在Linux系统上,select/epoll系统API支持多路复用IO。通过这两个API,一个系统调用可以监听多个Socket。只要Socket的读缓存中有数据,该方法就会立即返回。然后就可以阅读这个可读的Socket了。如果所有Socket读缓存为空,就会阻塞,即调用select/epoll的线程会被挂起。所以select/epoll本质上是阻塞IO,但是它们可以同时监听多个Socket。select和epoll的区别为什么多路复用IO模型会有两个系统API?我分析的原因是POSIX标准中定义了select,但是它的性能不够好,所以各个操作系统都引入了性能更好的API,比如Linux上的epoll,Windows上的IOCP。至于select为什么慢,大家比较认同的原因有两个:一是select方法返回后,需要遍历所有被监听的Socket,而不是变化的Socket。还有一点就是每次调用select方法时,都需要在用户态和内核态复制文件描述符的位图(通过调用3次copy_from_user方法复制read、write、exception三个位图).epoll可以避免上面提到的两点。Reactor多线程模型在Linux操作系统上,最可靠稳定的IO方式就是多路复用。我们的应用如何才能用好复用IO呢?经过前人多年的实践和总结,形成了Reactor模型,目前被广泛使用。著名的Netty和TomcatNIO都是基于这种模型。Reactor的核心是事件分发器和事件处理器。事件调度器是连接多路复用IO和网络数据处理的枢纽,监听Socket事件(select/epoll_wait)。然后将事件分发给事件处理器,事件分发器和事件处理器都可以基于线程池来完成。值得一提的是,Socket事件中主要有两种事件类型,一种是连接请求,一种是读写请求。连接请求处理成功后,会创建一个新的Socket,读写请求都是基于这个新创建的Socket。因此,在网络处理场景下,实现Reactor模式会有些绕口,但原理不变。具体实现可以参考DougLea的《Scalable IO in Java》(http://gee.cs.oswego.edu/dl/cpjslides/nio.pdf)。Reactor示意图Nginx多进程模型Nginx默认采用多进程模型,Nginx分为Master进程和Worker进程。真正负责监听网络请求和处理请求的只有Worker进程。所有的Worker进程都监听默认的80端口,但是每个请求只会被一个Worker进程处理。这里的奥秘在于:每个进程在接受请求前都必须竞争一把锁,只有获得锁的进程才有资格处理当前的网络请求。每个Worker进程只有一个主线程。单线程的优点是无锁处理,并发请求的无锁处理。这基本上是高并发场景下最好的状态。(参考http://www.dre.vanderbilt.edu/~schmidt/PDF/reactor-siemens.pdf)数据经过了网卡、操作系统、网络协议中间件(Tomcat、Netty等)的重重关卡),终于到了我们应用开发人员的手中,我们如何处理这些高并发的请求呢?我们从提高单机处理能力的角度来思考这个问题。要突破木桶理论,首先应该从提高单机处理能力的角度来思考这个问题。在实际应用场景中,问题的重点是如何提高CPU的利用率(谁说发展最快)。木桶理论说最短板决定水位,那为什么不提高短板IO的利用率,而是提高CPU的利用率呢?这个问题的答案是,在实际应用中,提高CPU利用率往往会同时提高IO利用率。当然,当IO利用率接近极限时,再提高CPU利用率就没有意义了。我们先来看看如何提高CPU利用率,再来看看如何提高IO利用率。并行与并发目前提高CPU利用率的主要方法是利用CPU的多核进行并行计算。并行性和并发性之间存在差异。在单核CPU上,我们可以同时听MP3和Coding。这是并发,但不是并行,因为从单核CPU的角度来说,不可能同时听MP3和Coding。只有多核时代才会有并行计算。并行计算太先进了。工业应用主要有两种模型,一种是共享内存模型,另一种是消息传递模型。共享内存模型的多线程设计模式的原理,基本来源于半个世纪前(1965)大师Dijkstra写的一篇论文《Cooperating sequential processes》。本文提出了著名的信号量概念。Java中用于线程同步的wait/notify也是semaphore的一种实现。师父的东西你看不懂,学不会你也不必自惭形秽。毕竟,师父的嫡系子孙并不多。在Toyo,一个叫HiroshiYuki的人总结了自己在多线程编程方面的经验,写了一本书,名字叫《JAVA多线程设计模式》,相当接地气(好理解)。下面我简单介绍一下。单线程执行是一种将多线程变成单线程的模式。当多个线程同时访问一个变量时,会出现各种莫名其妙的问题。这种设计模式直接把多线程变成了单线程,所以是安全的。当然,性能也下降了。最简单的实现是使用synchronized来保护有安全风险的代码块(方法)。并发领域有一个临界区的概念,我感觉跟这个模式是一样的。ImmutablePattern如果共享变量永不改变,多线程访问就不会有问题,永远是安全的。这种模式虽然简单,但是用得好,可以解决很多问题。GuardedSuspensionPatten实际上是一种等待通知模型。当不满足线程执行条件时,挂起(waiting)当前线程;当条件满足时,唤醒所有等待的线程(通知),Java语言中使用了synchronized和wait。/notifyAll快速实现等待通知模型。HiroshiYuki把这种模式总结为If的多线程版本,我觉得很贴切。Balking模式与之前的模式类似,不同的是当线程执行条件不满足时,直接退出,而不是像之前的模式那样挂起。这种用法的最佳应用场景是单例模式的多线程版本。当对象已经创建(不满足创建对象的条件)时,不需要创建对象(退出)。Producer-Consumer生产者-消费者模型在世界范围内广为人知。我接触最多的是一个线程处理IO(比如查询数据库),一个(或多个)线程处理IO数据,这样IO和CPU都能得到充分利用。如果生产者和消费者都是CPU密集型的,那么做生产者-消费者就是自找麻烦。Read-WriteLock解决了读多写少场景下的性能问题。它支持并行读取,但只允许一个线程进行写入操作。如果写操作非常非常少,但是并发读的量非常非常大,这时候可以考虑使用写时复制(copyonwrite)技术。我个人认为copyonwrite应该单独作为一种模式使用。Thread-Per-Message就是我们常说的一个线程一个请求。WorkerThread是一请求一线程的升级版,使用线程池解决线程频繁创建和销毁带来的性能问题。BIO时代的Tomcat就采用了这种模式。Future当你因为调用一个耗时的同步方法而心烦意乱,又想同时做其他事情时,可以考虑使用这种模式。这种模式的本质是一个同步到异步的转换器。之所以同步可以变异步,本质上是为了开启另一个线程,所以这种模式和一个请求一个线程有些关系。Two-PhaseTermination模式可以解决优雅终止线程的需求。线程专有存储线程专有存储是避免锁定和解锁开销的强大工具。C#中有一个支持并发的容器ConcurrentBag,就是采用了这种模式。地球上最快的数据库连接池HikariCP借鉴了ConcurrentBag的实现,构建了Java版本。有兴趣的同学可以参考。主动对象(更不用说这个)相当于伏龙十八掌的第一掌。它结合了前面的设计模式,有点复杂。个人认为引用的意义大于引用实现。最近国人也出了几本相关的书,不过总的来说还是YukiHiroshi的书更经得起推敲。要解决基于共享内存模型的并发问题,主要的问题就是用好锁。但是锁还是很难用好,所以后来有人开发了消息传递模型。消息传递模型共享内存模型比较难,你没有办法从理论上证明写的程序是正确的。一不小心,我们就会一直写出死锁程序。有问题,我们总会有高手。于是Message-Passing模型诞生了(它发生在1970年代)。Message-Passing模型有两个重要的分支,一个是Actor模型,一个是CSP模型。Actor模型Actor模型因Erlang和后来的Akka而声名鹊起。在Actor模型中,操作系统中没有进程和线程的概念。一切都是演员。我们可以将Actor视为一个更加通用和有用的线程。Actors是线性处理的(单线程),Actor之间以消息的形式进行交互,也就是说Actor之间是不允许共享数据的。没有共享,就不需要使用锁,避免了锁带来的各种副作用。Actor的创建与创建新对象没有什么不同。它非常快而且体积小,不像线程的创建那样慢而且耗资源。actor的调度不像线程,会引起操作系统的上下文切换(主要是保存和恢复各种寄存器),所以调度的消耗也很小。Actor也有一个有点争议的优势。Actor模型更接近真实世界。现实世界也是分布式的、异步的和基于消息的。特别是Actor对异常(失败)的处理、自愈、监控等更符合现实世界。逻辑。但是这个优势改变了编程的思维习惯。我们现在的大部分编程思维习惯,其实都和现实世界有很大的不同。一般来说,对改变我们思维习惯的事物的抵制总是超乎我们的想象。CSP模型Golang在语言层面支持CSP模型。CSP模型和Actor模型的一个感官区别是,在CSP模型中,生产者(消息发送者)和消费者(消息接收者)是完全松耦合的。生产者完全不知道消费者的存在。但是在Actor模型中,生产者必须知道消费者,否则没有办法发送消息。CSP模型类似于我们在多线程中提到的生产者消费者模型。我认为核心区别在于CSP模型中有类似绿色线程的东西。绿色线程在Golang中被称为协程。协程也是非常轻量级的调度单元,可以快速创建并且消耗很少的资源。Actor需要在一定程度上改变我们的思维方式,而CSP模型似乎没有那么动态,也更容易被现在的开发者所接受。都说Golang是工程语言,Actor和CSP的选择也可以看这个体现。除了消息传递模型,多样化世界还有事件驱动模型和函数模型。事件驱动模型类似于观察者模式。在actor模型中,消息的生产者必须知道发送消息的消费者。在事件驱动模型中,事件的消费者必须知道消息的生产者才能注册事件处理逻辑。Akka中的消费者可以跨网络。事件驱动模型的具体实现就像是Vertx。消费者还可以订阅跨网络事件。从这个角度来说,大家是在互相学习。
