服务器,并发,“事件驱动”的本质

主呵,是时候了。 -- 《秋日》

什么是服务器?

不就是提供“付费”、“免费”服务的高档电脑嘛!

你提到服务?

存储一个图片,读取一篇文字,观看一个动作片,计算一个账户存款,...

什么是并发?

不如讲一讲什么是不并发。

我有一台服务器,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 我们可以操作缓冲。包括申请一块内存做缓冲,填充这块缓冲,操作这块缓冲的数据(里边的小格子)。

如何并发?

  1. 多进程 多线程

    对于大量占用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,并且服务吞吐量并不特别高。

  2. IO 多路复用

    与操作系统达成协议,同时监测多个文件描述符(读写源头),操作系统提交程序控制权的时候,可以一次提交多个变动的描述符。从而可以一次控制多个源头读写。

    最典型的就是Unix提供的 select, poll。

    使用 select 模型的时候,工作过程是这样的:

    1. 首先打开多个文件描述符
    2. 交给操作系统处理数据读写
    3. 操作系统发现数据变动后,对这个标识符打上标签,停止阻塞,唤醒主程序
    4. 主程序遍历文件描述符,发现有变化的,就对其运行一个小任务
    5. 全部运行完任务,再次提供给操作系统
  3. 优化的 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
            ...
        }
    }
    
    1. 首先打开多个文件描述符,

    2. 交给操作系统处理数据读写

    3. 操作系统发现数据变动后,把变动的标识符放入一个变动描述符队列,停止阻塞,唤醒主程序

    4. 主程序遍历变动的文件描述符,对其运行一个小任务

    5. 全部运行完任务,再次提供给操作系统

我看不明白!

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投入到互联网上提供服务?

  1. 你需要一个服务器用来运行你的 CPU 密集型的业务,这台服务器是用 C 编写的,提供了高性能 CPU 和大量的内存用来提供可靠的快速的服务。

    这个服务提供 TCP UDP 级别的服务,也就是说通过套接字与其他进程通信。

    另外,这个服务应该使用多线程,对每一个进入的请求提供一个线程,并使用线程池提升反应质量。并要有一个良好的请求队列控制程序,以免请求过度,导致服务器崩溃。

    这些服务可以是自己编写的,也可以是第三方提供的,比如 Mysql, Orcale, ... 也可以是复杂的散点计算,或者大数据分析,... 他们通过套接字与下面的IO服务通信。

  2. 你需要一个服务器用来运行你的 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 就可以提供现成的服务。

  3. 把你的 IO 密集服务器和你的 CPU 密集服务器,通过内部局域网进行连接通信(套接字通信),这样你就提供了一个 CPU 极度密集的互联网服务。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,015评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,262评论 1 292
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,727评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,986评论 0 205
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,363评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,610评论 1 219
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,871评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,582评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,297评论 1 242
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,551评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,053评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,385评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,035评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,079评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,841评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,648评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,550评论 2 270

推荐阅读更多精彩内容