操作系统-软件架构设计

概述

对于开发者来说,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,其中直接是指没有用户级的缓冲,但操作系统本身的缓冲还是有的,两者的原理对比如下图:

操作系统-软件架构设计
在Linux系统中,内存映射文件对应的API是:

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

零拷贝

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

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

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

操作系统-软件架构设计
如上图所示,整个过程会有3次数据拷贝,不再经过应用程序内存,直接在内核空间中从内核缓冲区拷贝到Socket缓冲区。
但如果用零拷贝,可能连内核缓冲区到Socket缓冲区的拷贝也省略了。
如下图,内核缓冲区和Socket缓冲区之间并没做数据拷贝,只是一个地址映射,底层的网卡驱动程序要读取数据并发送到网络的时候,看似读的是Socket缓冲区的数据,但实际上直接读的是内核缓冲区的数据。

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

操作系统-软件架构设计
  1. 阻塞和非阻塞式从函数调用角度来说的,而同步与异步是从“读写是谁完成的”角度来说的。
    阻塞:如果读写没有就绪或者读写没有完成,则该函数一直等待。
    非阻塞:函数立即返回,然后让应用程序轮询。
    同步:读写由应用程序完成。
    异步:读写由操作系统完成,完成之后,回调或者事件通知应用程序。
  2. 按照这个定义可以知道,异步I/O一定是非阻塞I/O,不存在既是异步I/O,又是阻塞I/O,同步可能是阻塞的,也可能是非阻塞的。
  3. 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的个数根据上层决定,通常有几百个。

操作系统-软件架构设计
I/O线程只负责read/write事件的注册和监听,执行了epoll里面的的前两个阶段,第三个阶段是在worker线程里面做的。I/O线程监听到一个socket连接上有事件,于是把socket移交给worker线程,worker线程读出数据,处理完业务逻辑,直接返回给客户端。之所以可以这么操作,是因为I/O线程已经检测到读事件就绪,所以当worker线程在读的时候不会等待。I/O线程和worker线程之间交互,不再需要一来一回的两个队列,直接是一个socket集合。有兴趣的读者可以参看tomcat6 NIO源码,对此模型进行更为仔细的分析。
对于编写服务器程序,无论用epoll,还是Java NIO,或者基于Netty等网络框架,大体都是按照1+N+M的思路来做。另外,在实际的系统中,这里的M可能又会按照职责分成几组不同的线程,就变成了1+N+M1+M2+M3+……的模型。

进程、线程和协程

用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是一个本地方法。

在不同的JDK版本中,不同操作系统上面,该本地方法的实现有差异,此处不再进一步展开。
基于CAS,上层可以实现乐观锁、无锁队列、无锁链表。乐观锁会在后面讲述数据库的“丢失更新”问题时详细阐述。
无锁队列也是一个比较深入的话题,有不少的论文和文章都讨论过无锁队列的实现问题。
下面介绍JDK的JUC源码中使用的无锁队列的实现方法:
基于单向链表,维护一头一尾两个引用:head和tail。入队,就是在队列的尾部追加节点,多个线程通过CAS互斥的操作tail;出队,就是移除队列的头部节点,多个线程通过CAS互斥的操作head。
至于无锁链表,比无锁队列的实现要复杂一个等级。因为无锁队列只是操作头和尾,而无锁链表可以操作中间节点,有线程要插入节点,有线程要删除节点,要安全的实现并发并非易事,本文就不展开了。

参考

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

来源:融极

声明:本站部分文章及图片转载于互联网,内容版权归原作者所有,如本站任何资料有侵权请您尽早请联系jinwei@zod.com.cn进行处理,非常感谢!

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

相关推荐