美文网首页NodeJS in LNNMIT技术
服务器,并发,“事件驱动”的本质

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

作者: Tulayang | 来源:发表于2015-06-04 15:34 被阅读20549次

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

    什么是服务器?

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

    你提到服务?

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

    什么是并发?

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

    我有一台服务器,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 极度密集的互联网服务。

    相关文章

      网友评论

      • 0539c44157b1:《服务器,并发,“事件驱动”的本质 - 简书》写的挺不错的,已经收藏了。

        源码解析:http://sina.lt/fdcQ


        8afe04d66ee7:写的不错,谢谢博主;已 收 藏~
      • 勤奋happyfire:您好,想请问一个问题。我有一个基于Libev的udp服务器,服务器使用了唯一的udp socket,并设置为非阻塞,使用Libev的read事件回调执行recevfrom,但是发送数据就直接sendto了,没有使用write事件回调来sendto,因为我认为udp是没有发送缓冲的,数据即发即走,发送失败了就失败了。但是在线上压力大的时候,发现sendto会有Resource temporarily unavailable的错误,google后说sendto在非阻塞时也可能出现不能立即发送的情况,比如需要等待ARP确定mac地址,那么如果这样,安全的方法是不是使用Libev的write事件回调发送呢?主要是我没看到有人这么用过,楼主意下如何?谢谢!
        Tulayang:另外如果你仍然无法处理这个问题,我建议你先使用 nodejs 解决,使用其中的 dgram 模块,这是一个对 udp 的上层抽象和封装,并且对读和写进行了封装,也对写的缓冲控制提供了事件接口。

        http://nodejs.cn/doc/node/dgram.html 这个页面可以看看例子,只有 10 行左右的代码
        Tulayang:Libuv 的完整 udp 发送函数:

        int uv_udp_send(uv_udp_send_t* req, uv_udp_t* handle, const uv_buf_t bufs[], unsigned int nbufs, const struct sockaddr* addr, uv_udp_send_cb send_cb)
        Tulayang:不管是 TCP 还是 UDP,都是基于 socket 发送。socket 在操作系统实现上是存在内部缓冲区的。Libev 我没有研究过,Libuv 我倒是有不少经验。但不管怎么样,他们都是基于系统 socket 实现的,在底部可定有内存缓冲,因为操作系统处理任务是分片分时轮流处理的,数据绝对不可能立刻就发送到网线上,这就必须有一块缓冲内存。

        我不知道 Libev 是怎样实现的,但是如果其够健壮的话,应该有个返回值或者某个属性值可以获取当前缓冲的 Size,或者是有个回调函数表示这次 sendto 完全发送完毕了。

        拿 Libuv 来讲,uv_udp_send(..., uv_udp_send_cb send_cb) 是有一个 callback 的。另外,在 uv_udp_send 的参数是可以知道自己发送数据内存的 Size 的。这样,你可以多个:

        uv_udp_send()
        uv_udp_send()
        uv_udp_send()

        直到达到某个内存缓冲的 Size, 就等待最后一个 send 的 callback,再继续发送更多的数据。这样能保证你过度消耗内存。
      • d5673113dfa1:写得真不错,如果能够将一些函数写上去那就更加直白了
      • ed40b4c7ef53:写得真棒!
      • 爱在花语纷飞:醍醐灌顶啊,对于初学者给了很好的启发
      • Ste7enF:如果能加上分布式架构处理并发就更好了
      • 0d739f9dd004: 通俗易懂,不要放在简书上了,去infoq吧
      • 2004d8d7de20:这。。。。为啥在这写?发布到技术博客咯

      本文标题:服务器,并发,“事件驱动”的本质

      本文链接:https://www.haomeiwen.com/subject/jopdqttx.html