在做技术规划和架构设计的时候,我常常告诫技术人员,不要做过度设计,如果咱们只有1万用户,先别去操百万用户在线的心。淘宝那么大,也是从 Apache、PHP、MySql 发展起来的,没人能预见到淘宝会发展到这样一个规模,一旦发展起来,业务的爆发性增长会驱动技术的迅速发展,在业务规模还不及格的时候,不用为技术的未来担心。
这个思路在业务领域不会有太大的问题,因为需求的变化实在是太快了,需要时时去应对。但在底层技术的发展上,我们就有可能遭到「短视」的报复,比如:这个数据长度不会超过16位吧,这个程序不可能使用到2000年吧。于是就有了千年虫的问题,也有了 C10K 的问题。
C10K 就是 Client 10000 问题,即「在同时连接到服务器的客户端数量超过 10000 个的环境中,即便硬件性能足够, 依然无法正常提供服务」,简而言之,就是单机1万个并发连接问题。这个概念最早由 Dan Kegel 提出并发布于其个人站点(http://www.kegel.com/c10k.html)。
为什么会这样呢?因为计算机的上古时代,比如没有网络的 PC 时代,不会有程序员高瞻远瞩的预测到互联网时代的来临,也不会想到一台服务器会创建那么多的进程,即使在互联网初期,一台服务器能有100个在线用户已经是不得了的事情了。甚至,他们在设计 Unix 的 PID 的时候,采用了有符号的16位整数,这就导致一台计算机上能够创建出来的进程无法超过32767个。而计算机自己也得运行一些后台进程,这样应用软件能够创建的进程数就更少了。
当然,这个问题随着技术的发展很快就解决了,现在大部分的个人电脑操作系统可以创建64位的进程,由于数据类型所带来的进程数上限消失了,但是我们依然不能无限制的创建进程,因为随着并发连接数的上升会占用系统大量的内存,同样会造成系统的不可用。
操作系统里内存管理的主要作用是,进程请求内存的时候为其分配可用内存,进程释放后回收内存,并监控内存的使用状况。为了提高内存的使用率,现代操作系统需要程序能够共享内存,并且内存的限制对开发者透明,有些程序占用了内存空间,但不一定是一直使用的,这样可以把这部分内存数据序列化到磁盘上,需要的时候再加载到内存里,这样内存资源永远会给最需要的程序使用。于是程序员们发明了虚拟内存(Virtual Memory)。
虚拟内存技术支持程序访问比物理内存大得多的内存空间,也使得多个程序共享内存更加高效。物理内存由 RAM 芯片提供,虚拟内存则依靠透明的使用磁盘空间,使程序运行起来好像有了更大的内存空间。
但是问题依然存在,进程和线程的创建都需要消耗一定的内存,每创建一个栈空间,都会产生内存开销,当内存使用超过物理内存的时候,一部分数据就会持久化到磁盘上,随之而来的就是性能的大幅度下降。
这就像银行挤兑,人们把现金存入银行,收取一定的利息,平时只有少数人去银行取现,银行会拿人们存的钱去做更有价值的投资。但是,如果大部分人都去银行取现,银行是没有那么多现金的。取不到钱的用户,被门挡在外面的用户,一定会去拉横幅喊口号「最喜欢双截棍柔中带刚,不喜欢银行就上少林武当」云云,于是银行就处于不可用状态了。现在的 P2P 理财也是一个道理,投资者都去变现,无论是多么良性的资产,一样玩完。
为什么现在会有这么大的连接需求呢?因为业务驱动和技术发展嘛。除了普通的网页浏览和表单提交,即时通信和实时互动交流越来越成为主流需求,keep-alive 技术也能让浏览器产生长连接,实时在线的客户端越来越多,如果不能解决 C10K 问题,将导致服务商需要购买大量的服务器,而每一台服务器都不能做到物尽其用,即使你配置了更好的 CPU 和更大的内存。
当然,现在我们早已经突破了 C10K 这个瓶颈,具体的思路就是通过单个进程或线程服务于多个客户端请求,通过异步编程和事件触发机制替换轮训,IO 采用非阻塞的方式,减少不必要的性能损耗,等等。
底层的相关技术包括 epoll、kqueue 等,应用层面的解决方案包括 OpenResty、Golang、Node.js 等,比如 OpenResty 的介绍中是这么说的:
OpenResty 通过汇聚各种设计精良的 Nginx 模块,从而将 Nginx 有效地变成一个强大的通用 Web 应用平台。这样,Web 开发人员和系统工程师可以使用 Lua 脚本语言调动 Nginx 支持的各种 C 以及 Lua 模块,快速构造出足以胜任 C10K 乃至 C1000K 以上单机并发连接的高性能 Web 应用系统。
libevent
libevent 是以个C 库,并不是解决的方案,它支持多种 I/O 多路复用技术, epoll、 poll、 dev/poll、 select 和 kqueue 等。
一般用作网络库来使用。
epoll是linux所特有,而select是POSIX所规定,kqueue 是 FreeBSD 上的一种的多路复用机制。它是针对传统的 select/poll 处理大量的文件描述符性能较低效而开发出来的
据说现在都去搞 C10M 了,你们怕不怕?
实际操作中,每个解决方案都不是那么容易实现的,很多技术领域油光水滑的东西,放到线上,往往会出现各种各样的问题和毛病。松本行弘先生介绍了一个「最弱连接」的概念:
如果往两端用力拉一条由很多环 (连接)组成的锁链,其中最脆弱的一个连接会先断掉。因此,锁链整体的强度取决于其中最脆弱的一环。
C10K 问题的情况也很相似。一台服务器同时应付超过一万个(或者更多)并发连接的情况,哪怕只有一个要素没有考虑到超过一万个客户端的情况,这个要素就会成为「最弱连接」,从而导致问题的发生。
每个做架构设计和技术实现的程序员,都应当考虑这个最弱连接问题。
你是最弱的一环吗?
最初的服务器都是基于进程/线程模型的,新到来—个TCP连接,就需要分配1个进程(或者线程).而进程又杲操作系统录昂贵的资源,—台机器无法创建很多进程。如果C10k 个用户创建1万个进程,那么操作系统足无法承受的。如果是采用分布式系统,维持1亿用户在线需要10万台服务器
腾讯QQ也是有C10K问题的,只不过他们采用了UDP这种原始的包交换协议来实现的,绕开了这个难题.当然过程肯定痛苦的。如果当时有epoll技术,他们肯定会用TCP.后来的手机QQ,微信都采用TCP协议.
IO多路复用是一种系统调用,内核能够同时对多个IO描述符进行就绪检查。当所有被监听的IO都没有就绪时,调用将阻塞;当至少有一个IO描述符就绪时,调用将返回,用户代码可通过检查究竟是哪个IO就绪来进一步处理业务。显然,IO多路复用是解决系统里面存在N个IO描述符的问题的。
综上讨论,我们在进行实际的Socket编程的时候,无论是客户端还是服务端,大致有几种模式可以选择:
Linux下主要有以下五种I/O模型:
I/O模型
谈到 1/0 模型, 就不能不说当前主流且耳熟能详的 5 种模型: 阻塞 I/0、非阻塞 I/0、 I/0多路复用、 信号驱动 I/0 和异步 I/0。每种 1/0 模型都有典型的使用场景, 比如 Socket 的阻塞模式和非阻塞模式就对应于前两种模型, 而 Linux 中的 select 函数就属于 I/0 多路复用模型,至千第 5 种模型其实很少有 UNIX 和类 UNIX 系统支持, Windows 的 IOCP (I/0 Completion Port, 简称 IOCP) 属千此模型。 至千大名鼎鼎的 Linux epoll 模型, 则可以看作兼具第 3 种和第4种模型的特性。
进程会一直阻塞,直到数据拷贝完成 应用程序调用一个IO函数,导致应用程序阻塞,等待数据准备好。数据准备好后,从内核拷贝到用户空间,IO函数返回成功指示。阻塞IO模型图如下所示:
通过进程反复调用IO函数,在数据拷贝过程中,进程是阻塞的。模型图如下所示:
主要是select和epoll。一个线程可以对多个IO端口进行监听,当socket有读写事件时分发到具体的线程进行处理。模型如下所示:
另外,Richard Stevens 在《Unix 网络编程》卷1中提到的基于信号驱动的IO(Signal Driven IO)模型,由于该模型并不常用,本文不作涉及。接下来,我们详细分析四种常见的IO模型的实现原理。为了方便描述,我们统一使用IO的读操作作为示例。
信号驱动式I/O:首先我们允许Socket进行信号驱动IO,并安装一个信号处理函数,进程继续运行并不阻塞。当数据准备好时,进程会收到一个SIGIO信号,可以在信号处理函数中调用I/O操作函数处理数据。过程如下图所示:
相对于同步IO,异步IO不是顺序执行。用户进程进行aio_read系统调用之后,无论内核数据是否准备好,都会直接返回给用户进程,然后用户态进程可以去做别的事情。等到socket数据准备好了,内核直接复制数据给进程,然后从内核向进程发送通知。IO两个阶段,进程都是非阻塞的。异步过程如下图所示:
blocking IO
,non-blocking IO
,IO multiplexing
都属于synchronous IO
。注意到non-blocking IO
会一直轮询(polling)
,这个过程是没有阻塞的,但是recvfrom阶段blocking IO,non-blocking IO和IO multiplexing都是阻塞
的。参考网页:https://www.itcodemonkey.com/article/8293.html
多路复用(Multiplexing,又称“多工”)是一个通信和计算机网络领域的专业术语,在没有歧义的情况下,“多路复用”也可被称为“复用”。
多路复用通常表示在一个信道上传输多路信号或数据流的过程和技术
。因为多路复用能够将多个低速信道整合到一个高速信道进行传输,从而有效地利用了高速信道。通过使用多路复用,通信运营商可以避免维护多条线路,从而有效地节约运营成本
Java提供了哪些IO方式? NIO如何实现多路复用?
Java IO 方式有很多种,基于不同的 IO 抽象模型和交互方式,可以进行简单区分。
首先,传统的java.io包,它基于流模型实现,提供了我们最熟知的一些 IO 功能,比如 File 抽象、输入输出流等。 交互方式是同步、阻塞的方式,也就是说,在读取输入流或者写入输出流时, 在读、写动作完成之前,线程会一直阻塞在那里,它们之间的调用是可靠的线性顺序。
java.io 包的好处是代码比较简单、直观,缺点则是 IO 效率和扩展性存在局限性,容易成为应用性能的瓶颈。
很多时候,人们也把 java.net 下面提供的部分网络 API,
比如 Socket、ServerSocket、HttpURLConnection 也归类到同步阻塞 IO 类库,因为网络通信同样是 IO 行为。
在 Java 1.4 中引入了 NIO 框架(java.nio 包),提供了 Channel、Selector、Buffer 等新的抽象,可以构建多路复用的、同步非阻塞 IO 程序,同时提供了更接近操作系统底层的高性能数据操作方式。
在 Java 7 中,NIO 有了进一步的改进,也就是 NIO 2,引入了异步非阻塞 IO 方式,也有很多人叫它 AIO(Asynchronous IO)。
异步 IO 操作基于事件和回调机制,可以简单理解为,应用操作直接返回,而不会阻塞在那里,当后台处理完成,操作系统会通知相应线程进行后续工作。
结合None-blocking 的实现方式,将不停访问数据是否准备好的操作进行修改为:将访问修改多了通道channel 的方式,实现selector 方式,多路复用方式的实现。
注意:在之前只是会用代码写,但是不明白其中的原理。
为了解决同步阻塞I/O面临的一个链路需要一个线程处理的问题,后来有人对它的线程模型进行了优化一一一后端通过一个线程池来处理多个客户端的请求接入,形成客户端个数M:线程池最大线程数N的比例关系,其中M可以远远大于N.通过线程池可以灵活地调配线程资源,设置线程的最大值,防止由于海量并发接入导致线程耗尽。
采用线程池和任务队列可以实现一种叫做伪异步的 I/O 通信框架,它的模型图如上图所示。当有新的客户端接入时,将客户端的 Socket 封装成一个Task(该任务实现java.lang.Runnable接口)投递到后端的线程池中进行处理,JDK 的线程池维护一个消息队列和 N 个活跃线程,对消息队列中的任务进行处理。由于线程池可以设置消息队列的大小和最大线程数,因此,它的资源占用是可控的,无论多少个客户端并发访问,都不会导致资源的耗尽和宕机。
伪异步I/O通信框架采用了线程池实现,因此避免了为每个请求都创建一个独立线程造成的线程资源耗尽问题。不过因为它的底层仍然是同步阻塞的BIO模型,因此无法从根本上解决问题。
补充问题:
为什么大家都不愿意用 JDK 原生 NIO 进行开发呢?从上面的代码中大家都可以看出来,是真的难用!除了编程复杂、编程模型难之外,它还有以下让人诟病的问题:
Netty 的出现很大程度上改善了 JDK 原生 NIO 所存在的一些让人难以忍受的问题3
JAVA AIO框架在windows下使用windows IOCP技术,在Linux下使用epoll多路复用IO技术模拟异步IO,这个从JAVA AIO框架的部分类设计上就可以看出来。
linux 中的select poll epoll的区别
epoll 并没有实现异步方式,epoll /iocp 的多路复用都是的同步方式,这里进一步深入学习需要了解是如何实现异步方式的。
I/O多路复用和Socket ↩︎
https://juejin.im/post/5c725dbe51882575e37ef9ed ↩︎
BIO,NIO,AIO 的基本概念以及一些常见问题 ↩︎