操作系统-软件架构设计

概述

对于开发者来说,I/O是绕不过去的一个基本问题。从文件I/O到 络I/O,存在着各式各样的概念和I/O模型,所以这里首先把涉及I/O的各种概念和原理理清。

缓冲I/O和直接I/O

应用程序内存

是通过写代码用malloc/free、new/delete等分配出来的内存。

用户缓冲区

C语言的FILE结构体里面的buffer。

内核缓冲区

Linux操作系统的Page Cache。为了加快磁盘的I/O,Linux系统会把磁盘上的数据以Page为单位缓存在操作系统的内存里,这里的Page是Linux系统定义的一个逻辑概念,一个Page一般为4K。
对于缓冲I/O,一个读操作会有3次数据拷贝,一个写操作,有反向的3次数据拷贝;
读:磁盘->内核缓冲->用户缓冲区->应用程序内存;
写:应用程序内存->用户缓冲区->内核缓冲区->磁盘。
对于直接I/O,一个读操作,会有2次数据拷贝,一个写操作,有反向的2次数据拷贝;
读:磁盘->内核缓冲->应用程序内存;
写:应用程序内存->内核缓冲->磁盘。
所以,所谓的直接I/O,其中直接是指没有用户级的缓冲,但操作系统本身的缓冲还是有的,两者的原理对比如下图:

在java中,用MappedByteBuffer类可以实现同样的目的。

零拷贝

零拷贝(Zero Copy)是提升I/O效率的又一利器,熟悉Kafka实现原理的工程师应该知道,在消费消息的时候利用了零拷贝技术。当用户需要把文件中的数据发送到 络的时候,如果不用零拷贝,来看怎么实现。

实现方法1:利用直接I/O,伪代码如下:

如下图所示,整个过程有4次数据拷贝,读进来两次,写回去两次。
磁盘->内核缓冲区->应用程序内存->Socket缓冲区-> 络

在这里需要分清“映射”和“拷贝”的区别。拷贝是把数据从一块内存中复制到另一块内存里;映射相当于只是持有了数据的一个的引用(或者叫地址),数据本身只有一份。

  • 阻塞和非阻塞式从函数调用角度来说的,而同步与异步是从“读写是谁完成的”角度来说的。
    阻塞:如果读写没有就绪或者读写没有完成,则该函数一直等待。
    非阻塞:函数立即返回,然后让应用程序轮询。
    同步:读写由应用程序完成。
    异步:读写由操作系统完成,完成之后,回调或者事件通知应用程序。
  • 按照这个定义可以知道,异步I/O一定是非阻塞I/O,不存在既是异步I/O,又是阻塞I/O,同步可能是阻塞的,也可能是非阻塞的。
  • I/O多路复用(select,poll,epoll)都是同步I/O,因为read和write函数操作都是应用程序完成的,同时也是阻塞的,因为select,read,write的调用都是阻塞的。
    除了上面的四种I/O,还经常听到“事件驱动”一词。这个词在不同的语境中有不同的意思。比如Nginx中所讲的“事件驱动”,其实是Nginx封装的一个逻辑概念,在操作系统层面是基于epoll或者select来实现的。
    所以,当将 络I/O模型的时候,一定要注意将的是操作系统层面的I/O模型,还是上次 络框架封装出来的I/O模型(比如asio,比如说Java的NIO,在Linux平台上,底层都是基于epoll的)。
    另外,对于“异步I/O”一词,在操作系统的语境和上层应用的语境中,往往指代不一样,在操作系统的语境里,异步 I/O指IOCP或者aio这种真正的异步,epoll不被认为是异步I/O,但在上层应用的语境里,异步I/O往往指的是JavaJDK或者 络框架(Netty)封装出来的概念,底层实现可能是epoll,也可能是真正的异步I/O。
    所以在本书后续的章节提到的“异步I/O”,主要指应用层面的语境(底层可能是epoll也可能是真正的异步I/O)。
    在高并发章节,会把“异步”一词扩展到其他领域,从而对“异步”进行更深入的探讨。
  • Reactor模式与Passivity模式

    除了上文所说的四种I/O模型,大家还会经常听到Reactor模式和Passivity模式。它是 络框架的两种模式,无论操作系统的 络I/O模型的设计,还是上层 络框架的 络I/O模型的设计,用于都是这两种设计模式之一。

    1. Reactor模式:主动模式。所谓主动,是指应用程序不断的轮询,询问操作系统或者 络框架,I/O是否就绪。Linux系统下的select,poll,epoll就属于主动模式,需要应用程序中有一个循环一直轮询;Java中的NIO也是属于这种模式。在这种模式下,实际的I/O操作还是应用程序执行的。
    2. Passivity模式:被动模式。应用程序把read和write函数操作全部交给操作系统或者 络框架,实际的I/O操作有操作系统或者 络框架完成,之后在回调应用程序。asio库就是典型的Passivity模式。
      所以,上文提到的应用层面的语境所说的“异步I/O”是Passivity模式。

    select、epoll与LT与ET

    因为epoll是Linux服务器开放的主流 络I/O模型,Java NIO在linux平台也是基于epoll实现的,下面对epoll连同select、poll进行介绍。

    select

    关于此函数,有几点说明:

    • 因为fd是一个int值,所以fd_set 其实是一个bit数组,每1位表示一个fd是否有读事件或者写事件发生。
    • 第一个参数是readfds或者writefds的下标的最大值+1。因为fd从0开始,+1才表示个数。
    • 返回结果还在readfds或者writefds里面,操作系统会重置所有的bit位,告知应用程序到底那个fd上面有事件,应用程序需要自己从0到maxfds-1遍历所有的fd,然后执行相应的read/write操作。
    • 每次当select调用返回后,在下一次调用之前,要重新维护readfds和writefds。

    poll

    通过上面的函数会发现,select、poll每次调用都需要应用程序把fd的数组传进去,这个fd的数组每次都要在用户态和内核态直接传递,影响效率。为此,epoll设计了“逻辑上的epfd”。epfd是一个数字,把fd数组关联到上面,然后每次向内核传递的是epfd这个数字。

    epoll

    整个epoll过程分成三个步骤:

    1. 事件注册。通过函数epoll_ctl实现。对于服务器而言,是accept、read、write三种事件;对于客户端而言,是connect、read、write三种事件。
    2. 轮询这三个事件是否就绪。通过函数epoll_wait实现。有事件发生,该函数返回。
    3. 事件就绪,执行实际的I/O操作。通过函数accept、read、write实现。
      这里要特别解释一下什么是“事件就绪”:
    • read事件就绪:这个很好理解,就是远程有新数据来了,socket读取缓冲区里有数据,需要调用read函数处理。
    • write事件就绪:是指本地的socket写缓冲区是否可写。如果写缓冲区没有满,则一直是可写的,write事件一直是就绪的,可以调用write函数。只有当遇到发送大文件的场景,socket写缓冲区被占满时,write事件才不是就绪状态。
    • accept事件就绪:有新的连接进入,需要调用accept函数处理。

    epoll的LT和ET模式

    epoll里面有两种模式,LT(水平触发)和ET(边缘触发)。水平触发又称条件触发,边缘触发又称状态触发。
    水平触发:读缓冲区只要不为空,就会一直触发读事件;写缓冲区只要不满,就好一直触发写事件。
    边缘触发:读缓冲区的状态,从空转为非空的时候触发一次;写缓冲区的状态,从满转为非满的时候触发一次。比如用户发送一个大文件,把写缓冲区塞满了,之后缓存区可以写了,就好发生一次从满到不满的切换。
    关于LT和ET,有两个要注意的问题:

    • 对应LT模式,要避免“写的死循环”问题:写缓冲区为满的概率很小,即“写的条件”会一直满足,所以当用户注册了写事件却没有数据要写时,它会一直触发,因此在LT模式下写完数据一定要取消写事件。
    • 对于ET模式,要避免“short read”的问题:例如用户收到100个字节,它触发1次,但用户只读50个字节,剩下的50个字节不读,它也不会触发。因此在ET模式下,一定要把“读缓冲区”的数据一次性读完。
      在实际开发中,大家一般都倾向于用LT,这也是模式的模式,Java NIO用的也是epoll的LT模式。因为ET容易漏事件,一次触发如果没有处理好,就没有第二次机会了。虽然LT重复触发可能有少许的性能消耗,但代码写起来更安全。

    服务器编程的1+N+M模型

    在服务器的编程中,epoll编程的三个步骤是由不同的线程负责的,即服务器编程的1+N+M模型。
    如下图,整个服务器有1+N+M个线程,一个监听线程,N个I/O线程,M个Worker线程。N的个数通常等于CPU核数,M的个数根据上层决定,通常有几百个。

    用Java的人通常写的是“单进程多线程”的程序;而用C++的人,可能写的是“单进程多线程”、“单进程单线程”、“多进程多线程”的程序(这里主要指Linux系统上的服务器程序)。之所以会有这样的差异,是因为Java程序并不直接运行在Linux系统上,而是运行在JVM之上。而一个JVM实例是一个Linux进程,每个JVM都是一个独立的“沙盒”,JVM之间相互独立,互不通信。所以Java程序只能在这一个进程里面,开多个线程实现并发。而C++直接运行在Linux系统上,可以直接利用Linux系统提供的强大的进程间通信机制(IPC),很容易创建多个进程,并实现进程间的通信。
    “多进程多线程”是“单进程多线程”和“多进程单线程”的组合体,其原理并没有差异,所以接下来只讨论“单进程多线程”和“多进程单线程”两种编程模型,对比“多进程”和“多线程”的关键差异。

    为什么要多线程

    对于客户端程序,有UI交互界面,多线程不可避免,这类程序不在讨论之列。本节注意讨论的是服务器端的程序。
    这里所说的“多”线程,是指运行几百个业务线程的服务器程序。如果是4核CPU,运行4个线程,本质上仍是单线程。之所以要开多线程,是因为服务器端的程序往往是I/O密集型的应用。举个极端点的例子,假设程序没有任何I/O(磁盘I/O或 络I/O),纯粹的CPU计算,如同一个最简单的、空的死循环,只需要一个线程就可以把一个CPU的核占满。
    所以,多线程主要是应对I/O密集型的应用。多线程能带来两方面的好处:

    • 提高CPU利用率。通俗地讲,不能让CPU空闲着。当一个线程发送I/O时,会把该线程从CPU上调度下来,并把其他的线程调度上去,继续计算。
    • 提高I/O吞吐。典型的场景是,应用程序连接的Redis或者MySQL,他们提供的都是同步接口,一次只能处理一个请求。要想并发,办法是通过连接池和多线程,实现每个线程使用一个连接。好比在客户端和服务器之间开了多条通道,并行传输数据。

    除了多线程,线程间的同步机制也非常复杂,在此只列举线程间的常用同步机制:

    • 锁(悲观锁、乐观锁、互斥锁、读写锁、自旋锁、公平锁、非公平锁)。
    • Wait与Signal。
    • Condition。
      无论C++开发者在Linux系统中使用是pthread,还是Java开发者使用JUC库,都有这些基本机制。基于这些基本机制,又可以封装出各式各样的、便于应用层使用的同步机制,比如信 量、Future、线程池,还可以封装出各式各样的线程安全的数据结构,比如阻塞队列、并发HashMap等。

    多进程

    既然多线程可以实现并发,那为什么还要设计多进程呢为线程存在的两个问题,一是线程间内存共享,要加线程锁;而加锁后会导致并发效率下降,同时复杂的加锁机制也将增加编码的难道;二是过多的线程造成线程间的上下文切换,导致效率低下。
    在并发编程领域,一直有一个很重要的设计原则:“不要通过共享内存来实现通信,而应通过通信实现共享内存。”这句话不太好理解,换成通俗的说法就是:“尽可能通过消息通信,而不是共享内存来实现进程或者线程之间的同步。”
    进程是资源分配的基本单位,进程间不共享资源,通过管道或者Socket方式通信(当然也可以共享内存),这种通信方式天生符合上面的并发设计原则。而对于大多线程,大家习惯于共享内存,然后通过各种加锁来实现同步。虽然在多线程领域也有这种思想的实现,比如Akka框架,但流行程度仍然不够。
    除锁的问题之外,多进程还带来另外两个好处:一是减少了多线程在不同CPU核切换的开销;另外多进程相互独立,意味着其中一个崩溃后,其他进程可以继续运行,这对程序的可靠性很有帮助。
    多进程模型的典型例子是Nginx。Nginx有一个Master进程,N个Worker进程,每个Worker进程对应一个CPU核,每个进程都是单线程的。Master进程不接收请求,负责管理功能;各个Worker进程间相互独立,并行地接收客户端的请求,也不需要向多线程那样在不同的CPU核间切换。
    有了多进程后,在每个进程内部,可能是单线程,也可能是多线程,这往往取决与I/O。
    比如Redis就是单进程单线程的模型(这里说的单线程模型,不是指整个Redis服务器只有一个线程,而是指接收并处理客户端请求的线程只有一个)。之所以单线程可以支持,是因为在请求接收的地方用的是epoll的I/O多路复用,在请求处理的地方又完全是内存操作,没有磁盘或者 络I/O,所以只需单线程就足够了。要利用多核也很简单,开多个Redis实例就可以了。
    但对于I/O密集型的应用,要提高I/O效率,则需要下面几种办法:

    • 异步I/O。如果客户端、服务端都是自己写的,比如RPC调用,则可以把所有的I/O都异步化(利用epoll或者真正的异步I/O)。异步化之后,请求可以Pipeline处理,就不需要多线程了。但像MySQL的JDBC提供的都是同步接口,不支持I/O异步。
    • 多线程。I/O不支持异步,就只能开多个线程,每个线程都是同步地调用I/O,实际上是用多线程模拟了异步I/O。典型例子是Web应用服务器调用Redis或MySQL。
    • 多协程。

    多协程

    多协程除锁的问题之外,还要一个问题是线程太多,切换的开销很大。虽然线程切换的开销比进程切换的开销小很多,但还是不够。以常用的Tomcat服务器为例,在通常配置的机器上最多也只能开几百个线程。如果再多,则线程切换的开销太大,并发效率反而会下降,这意味着Tomcat最多只能并发地处理几百个请求。但如果是协程的话,可以开几万个!协程相比线程,有两个关键特点:

    • 更好的利用CPU:线程的调用时操作系统完成的,应用程序干预不了,协程可以由应用程序自己调度。
    • 更好的利用内存:协程的堆栈大小不是固定的,用多少申请多少,内存利用率更高。
      现代的编程语言像GO、Rust,原生就有协程的支持,但偏传统的Java、C++等语言没有原生支持。因此,产生一些第三方的方案,比如Java的Quasar Fiber、微信团队为C++研发的libco等,但普及程度还比较低,开发者还是习惯多线程的开发模型。
      最后,总结了多线程、多进程和多协程编程模型的对比。
      操作系统-软件架构设计

    无锁(内存屏障与CAS)

    虽然多线程的编程模型功能强大,应用也很普及,但始终绕不开锁的问题。为了提升锁的效率,前辈大师们想了诸多办法,在多线程中设计了无锁数据结构。下面就来探讨一下无锁数据结构及其背后的原理。

    内存屏障

    内存屏障的两个核心点:

    • 读可以是多线程,写必须是单线程,也称Single-Writer Principle。如果是多线程写,则做不到无锁。
    • 从用法来讲,内存屏障是在两行代码之间插入一个栅栏,也就是栅栏之间的代码不能被指令重排,执行后数据必须被刷入主存,数据被其他线程可见。
      基于内存屏障,有了Java的volatile关键字,再加上单线程写的原则,就有了Java无锁开发框架-Disruptor,其核心就是“一写多读,完全无锁”。

    CAS

    如果是多线程写,则内存屏障也不够用了,这时要用到CAS。CAS是在CPU层面提供的一个硬件原子指令,实现对一个值的Compare和Set两个操作的原子化。
    下面展示了JDK6中,CAS函数的源代码,unsafe类的compareAndSwapInt是一个本地方法。

    参考

    《软件架构设计:大型 站技术架构与业务架构融合之道》

    声明:本站部分文章及图片源自用户投稿,如本站任何资料有侵权请您尽早请联系jinwei@zod.com.cn进行处理,非常感谢!

    上一篇 2021年11月6日
    下一篇 2021年11月6日

    相关推荐