主呵,是时候了。 -- 《秋日》
什么是服务器?
不就是提供“付费”、“免费”服务的高档电脑嘛!
你提到服务?
存储一个图片,读取一篇文字,观看一个动作片,计算一个账户存款,...
什么是并发?
不如讲一讲什么是不并发。
我有一台服务器,1核CPU,连接到互联网提供服务。在09:00时刻,突然有100个用户同时要看服务器的数据,服务器怎么办?
+-------+ 09:00
| |
| 服务器 |
| |
+-------+
|
|
----------------------------
互联网
----------------------------
| | | ...... |
客户1 客户2 客户3 客户100
服务器:
--> 读取客户1的请求, 验证客户身份, 把数据发送给你, 用时1秒 [ 客户2到100等待中 ]
--> 读取客户2的请求, 验证客户身份, 把数据发送给你, 用时1秒 [ 客户3到100等待中 ]
--> 读取客户3的请求, 验证客户身份, 把数据发送给你, 用时1秒 [ 客户4到100等待中 ]
........................................................................
--> 读取客户100的请求,验证客户身份, 把数据发送给你, 用时1秒 []
这就是“不并发”,即“迭代”,也就是“循环”的意思。
迭代 == 循环
既然来了100个客户,那么一个一个的处理,循环从客户1一直到客户100。处理完成客户1才去处理客户2,...。这样我们可以看出:
- 客户1从发出请求到收到响应,等待了1秒
- 客户2从发出请求到收到响应,等待了2秒
- 客户3从发出请求到收到响应,等待了3秒
- ..................................
- 客户100从发出请求到收到响应,等待了100秒
这就是“不并发”的问题,同时来100个客户,这些用户会排起长长的队伍,等待很长的时候,服务器才会去为他服务。客户可不喜欢这样的地方。
把服务器比喻成一个KFC,那么“不并发”就意味着只提供一个服务员,来了100个客户,当然要排个长长的队伍了。
+-------+ 09:00
| |
| KFC |
| |
+-- S --+
客户1
客户2
客户3
......
客户100
那么,如何并发?
这个问题太广泛了,需要先从 "CPU" "操作系统" "进程" 开始。
CPU 操作系统 进程
操作系统运行时,采用“抢占”的方式。当今绝大多数操作系统采用资源“抢占”。资源就是CPU计算,内存使用,磁盘读写,...基于此设计,在单核CPU的环境里,可以同时运行多个进程。
操作系统会划分时间片,并使用一个任务队列,把每个进程每个阶段的任务分配一个时间片,比如1ms(实际小的多)。1ms运行进程的任务,没有完成就挂起放入队列末端,下一次再运行。然后操作系统运行任务队列的下一个任务。
比如有个进程,他的任务是打开一个文件,然后读取100个字符,并把文件写入10个字符。
操作系统会运行进程,先打开文件,如果这时候时间片时间到了,挂起进程,放入队列后面,运行下一个进程。
当操作系统根据任务队列的前进,又一次到达这个进程,操作系统读取100个字符,时间到,挂起进程,放入队列后面,运行下一个进程。
... 如此重复 ...
时间片?
事实上,操作系统被聪明的设计,即便是单核CPU,也可以同时运行多个进程。操作系统经常同时运行多个进程,比如 Photoshop, Firefox, Vim, ... 他们是同时运行的,而且能“同时”工作。
对于进程,操作系统不会把 CPU 和内存一直放在某个进程中。如果这样,当有个进程耗费时间特别长时,其他的进程就罢工了,也就无法同时运行多进程了。
所以,操作系统会给每个进程一个时间片,即运行进程中任务的时间上限,到达时间后会挂起进程,放入任务队列后面,直到下一次任务队列取出这个进程任务。
任务队列?
操作系统使用一个线性的队列,管理ta自己的工作流程。操作系统不停地取出任务,运行,取出任务,运行,...
从编程的角度观看,就好比是一个数组。
IO的根本:内核缓冲区
对磁盘写需要花费大量时间,而对内存写则小的多。
一个高效的做法是:在内核区域开辟一块内存,用来放置读取和写入的内容。
比如
程序员在9:00写入100个字符,这些字符被复制到内核缓冲区中,这是属于内核的一块内存。
在9:10写入20个字符,这些字符也被复制到内核缓冲区中。
在某个时间,比如9:30,操作系统把内核缓冲区中写入的所有内容排序,然后一次性写入到磁盘。
从9:00到9:30之间可能写入了几百次内核缓冲区,但都是在内存区域,速度会很快,而在9:30只进行了一次磁盘写入。这样把数百次的写入操作集合成一次磁盘写入。从而减少磁盘写入次数。
----------+----------------+----------------+----------+---------------> 时间线
[100个字符] [20个字符] ...
9:00 |写入 9:10 |写入 9:xx|写入
v v v
+-----------------------------------------------------------------+
| 内核缓冲区 |
+-----------------------------------------------------------------+
9:30 | 写入
v
+-----------------------------------------------------------------+
| 磁盘 |
+-----------------------------------------------------------------+
实际的计算机其运行速度非常高,9:00~9:30只不过是我们的人为假设,计算机这段时间间隔大概只有1分钟,或者更低,而在这1分钟内,可能已经运行了成百上千次不同的写入。
缓冲?
缓冲是一块内存,里面放着乱糟糟的东西。内存由小格子组成,每个小格子代表一位,可以放置一个0或者1。8个小格子称为1个字节,1024个小格子称为1千个字节,二进制都是用2的倍数表示,所以2进制的1千是1024。
计算机启动后,内存中的每个小格子都是有值的,我没有深入研究过初始是什么值。但是我们可以假定是0.
现在,需要一块缓冲,那就是从内存中拿出一块没有使用的区域,里边是很多小格子,每个小格子放置了0或者1。小格子中肯定有0或者1,不可能是空白的。
既然小格子都是0 1,那么这块内存中就有一些不确定的数值。这些数值在开始是无用的数据。这块内存可能刚才被某个进程使用过,存储了一些用户的账号密码,然后你的这块内存还放着这些数据。但是这些数据对你现在当前的进程是没用的。
使用赋值可以覆盖掉原有的格子中的值。小格子被新的0 1填充,获得一个新的数据。
看到这里你也应该明白了,如果我们申请了长度是64个小格子的内存,也就是可以放置64个0 1的内存,64个小格子是8个字节,可以放置8个ASCII字符,4个JavaScript字符(16位)。
如果我们在小格子里只填充了32个,那么剩下的32个是一些混乱的数据,我们不需要,所以我们需要精确定位要使用的小格子数。
也就是4个字节。我们填充4个字节的数据,然后操作这块缓冲的时候,也只操作4个字节的(读出到另一块内存,或者写入其他文件)。剩下的4个字节就不要去动,那些是混乱的数据。
Buffer 对象就是Node.js对缓冲的一个对象表示,通过提供的函数 API 我们可以操作缓冲。包括申请一块内存做缓冲,填充这块缓冲,操作这块缓冲的数据(里边的小格子)。
如何并发?
-
多进程 多线程
对于大量占用CPU的程序,如果要给1千个人同时提供CPU使用,最理想的状态就是提供1000个CPU,每个CPU占用一个线程。
不过现实还没有这么多核的服务器。如果我们没钱,那么我们只有4个核,充其量我们可以提供4个CPU服务。也就是同时并行4个线程。同时可以为4个客户提供CPU计算服务。
然而,大部分客户在使用服务的时间中,需要CPU计算的时间比例较少。
当你需要CPU密集的服务时,C语言是最好的选择,过去貌似更多的选择C++,但是现在的流行表示,以C++为代表的面向对象只会把程序搞得臃肿难以扩展。许多人在对C C++的反思后,仍然认为C才是最有价值的。比如版本管理系统Git,简洁有序。比如Redis,快速简洁。
C能做的是,用最简单的代码表达内容,获得最快速的CPU和内存操作。
多进程,为每一个客户启动一个进程提供服务。
多线程,在一个进程内为每一个客户提供一个线程服务。多进程和多线程的过程是相似的,但是多线程的内存开销要比进程少,并且切换速度快一些,但是多线程编程会变复杂,并且会出现多个线程同时操作同一个数据,从而引入锁的问题。
当CPU密集时,显然需要多线程更好一些,每一个客户连接对应一个线程。因为在这样的系统里,每一个任务都没有等待的机会,所有的内容都一直在不停地运算,直到结束。
数据库就是很好的代表。
---> 连接进入 ---> 领取一个线程 ---> 计算 ---> 返回 ---> 收回线程 ---> 连接进入 ---> 领取一个线程 ---> 计算 ---> 返回 ---> 收回线程 ---> 连接进入 ---> 领取一个线程 ---> 计算 ---> 返回 ---> 收回线程 ...........................................................
理想情况下,进入 1万 个用户,我们希望有 1万 个线程在同时处理任务。显然,事实上硬件还达不到。这就需要一些操作系统排队。而一旦进入排队,后续进入的客户就会进入等待,他们会明显的感受到延迟的存在。
比如进入 1万 个用户,很有可能 10 个在运行计算,另外的在操作系统中排队。
这样的服务需要多核速度很快的 CPU,并且服务吞吐量并不特别高。
-
IO 多路复用
与操作系统达成协议,同时监测多个文件描述符(读写源头),操作系统提交程序控制权的时候,可以一次提交多个变动的描述符。从而可以一次控制多个源头读写。
最典型的就是Unix提供的 select, poll。
使用 select 模型的时候,工作过程是这样的:
- 首先打开多个文件描述符
- 交给操作系统处理数据读写
- 操作系统发现数据变动后,对这个标识符打上标签,停止阻塞,唤醒主程序
- 主程序遍历文件描述符,发现有变化的,就对其运行一个小任务
- 全部运行完任务,再次提供给操作系统
-
优化的 IO 多路复用 epoll kqueue
当操作系统发出通知后,我们使用一个小缓冲内存,读取数据中的一块,并运行任务,然后交回操作系统。因为处理的数据量很小,所以感觉上去像是没有阻塞。
交给操作系统后,操作系统就可以再次加入新变动的描述符用于下一次的任务。
文件描述符:是数据可以读写的源头表示,比如一个文件描述符,就是代表可以读写的文件。一个套接字描述符,也是可以读写的,网络进入出去的数据使用“套接字描述符”这个术语来表示。
config fds[1, 2, 3, ...] // 配置文件描述符,他们关联了数据读写的源头 loop { // 循环运行 change_fds = epoll wait fds // 交给操作系统,并等待(睡眠) forEach change_fds { // 当操作系统通知时,会把变动的描述符放入change_fds if fd === socket in read socket if fd === file a write file a if fd === socket out write socket ... } }
首先打开多个文件描述符,
交给操作系统处理数据读写
操作系统发现数据变动后,把变动的标识符放入一个变动描述符队列,停止阻塞,唤醒主程序
主程序遍历变动的文件描述符,对其运行一个小任务
全部运行完任务,再次提供给操作系统
我看不明白!
Apache 在以往的服务中提供多线程服务器模型,Nginx 提供IO多路复用的模型。
流行的数据库,像 Mysql,采用多线程模型,因为 ta 面对的是密集的数据操作。而应用服务器面对的是套接字的读取写入,等待,很多时候,客户都是没有数据可以收发的。
每个平台实现了不同的 IO 多路复用,Linux 采用 epoll,BSD 采用 kqueue,还有的没有采用,停留在多线程。libev是libevent的新版本,采用了统一封装,针对不同平台使用不同的IO吞吐。而在上层的编码中,采用统一的函数库。
Node.js,底层是 C 编写的 libev 框架,libev 在 Linux,BSD Unix上分别是用 epoll kqueue 多路复用模型,这在编程的抽象层常被叫做事件驱动。事实上,ta 是多路复用,同时监测多个文件描述符,采用非阻塞读写,从而在单线程进行并发。
非阻塞?IO...
所谓阻塞,是进程会进入睡眠,从而不再提供服务,直到读写的数据已经被放到内核缓冲区,操作系统内核会再次唤醒进程。
普通文件,也就是操作系统磁盘的文件,一般没有读和写的阻塞,一旦通过open打开后,会立刻有内存的映射。你可以读这个文件到内存,然后再写入别的地方。这些操作不需要等待数据准备,操作是直接运行的,时间花费在磁盘寻址和内存复制,没有数据准备的等待。
网络套接字数据被认为要到来时,会有一些等待期,被认为是阻塞。比如网络的数据要一条一条传过来,期间要经过漫长的光纤。
套接字是怎么利用多路复用无阻塞读写的?
首先套接字是个读写双工模式的。套接字的数据来源于网络,并从网络发布出去。因为此,每一阶段从网络来的数据量非常小。可以比作一个水龙头,虽然水(数据)确实一直不停地从水龙头中涌出,但是每一点的水量都是非常小的,CPU 内存处理这点流量几乎不费吹灰之力。
所以,程序可以不停地检测到数据流入,并且挤满内核缓冲区,然后wait完毕,操作系统内核通知进程(这个时间非常的短,CPU是很快的),进程读走内核缓冲区的内容,并返还给操作系统控制权。操作系统再次把内核缓冲区写满,然后通知进程,...,如此,周而复始,直到没有数据变动了,操作系统就一直wait,进程则一直睡眠,直到有新的数据变化。
每个循环阶段,每个读写占用的时间和读写的数据量都是小块的,几乎可以看做瞬时。完成后立刻交还给操作系统控制权,等待操作系统内核下一次的通知。
一个大文件如何在写入读取时不造成其他客户等待?
一个大型文件,可以使用一个游标记录每次读写的位置,每次只读写一小块,然后记录游标,停止读写,并返回到循环,进入等待。当操作系统下一次发出通知时,读写游标后面的一小块,并如此重复,直到完全读写。这样可以在最小的时间返还操作系统的控制权,以此达到无阻塞。
我如何搭建我的超级服务器?
你需要运行一个CPU极度密集的业务,并把ta投入到互联网上提供服务?
-
你需要一个服务器用来运行你的 CPU 密集型的业务,这台服务器是用 C 编写的,提供了高性能 CPU 和大量的内存用来提供可靠的快速的服务。
这个服务提供 TCP UDP 级别的服务,也就是说通过套接字与其他进程通信。
另外,这个服务应该使用多线程,对每一个进入的请求提供一个线程,并使用线程池提升反应质量。并要有一个良好的请求队列控制程序,以免请求过度,导致服务器崩溃。
这些服务可以是自己编写的,也可以是第三方提供的,比如 Mysql, Orcale, ... 也可以是复杂的散点计算,或者大数据分析,... 他们通过套接字与下面的IO服务通信。
-
你需要一个服务器用来运行你的 IO 密集型的业务,一旦你的服务是面向网络,那么就意味着你需要验证成千上万的客户,这些业务内容不需要大量的 CPU 计算,就算是循环也可能只在100次量级之内。这时候应该使用编写更快速,更容易管理的语言,比如 Node.js,Python,Ruby。
这样的语言编写出来的程序,更容易扩展,和与他人合作。因为是IO服务,所以不存在计算的性能问题,存在的差别则是IO吞吐上。
基于 IO 服务器的演化,大致经历了多进程 -> 多线程 -> select poll多路复用 -> epoll kqueue 多路复用。现在最快速的 IO 服务器是采用 epoll kqueue 多路复用,比如 Nginx。Node.js 本身就是 epoll 的,其核心是基于 epoll 的 libev 事件驱动库。
如果你打算使用 Python,Ruby,选用他们的 epoll kqueue 事件驱动库,要比多线程库快速并稳定的多。
说明:epoll 只能在Linux服务器使用,在 BSD Unix 则是采用了kqueue,与 epoll 达到相似的目的。libev 在底层对多个平台进行了统一封装。
另外,基于事件驱动的服务,也更容易水平扩展,搭建集群可以将服务器拓展至几十个。
福利:甚至对于拓展的管理程序,Nginx 就可以提供现成的服务。
把你的 IO 密集服务器和你的 CPU 密集服务器,通过内部局域网进行连接通信(套接字通信),这样你就提供了一个 CPU 极度密集的互联网服务。