操作系统-软件架构设计

概述

对于开发者来说,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,其中直接是指没有用户级的缓冲,但操作系统本身的缓冲还是有的,两者的原理对比如下图:
在这里插入图片描述
关于缓冲I/O和直接I/O,有几点需要特别说明:

  1. fflush和fsync的区别:fflush是缓冲I/O中的一个API,它只是把数据从用户缓冲区刷到内核缓冲区而已,fsync则是把数据从内核缓冲区刷到磁盘里。
    这意味着无论缓冲I/O,还是直接I/O,如果在写数据之后不调用fsync,此时系统断电重启。最新的部分数据会丢失,因为数据还在内核缓冲区里面,操作系统还没来得及把数据刷到磁盘。下面讲数据库、数据一致性,会反复提到这个fsync函数。
  2. 对于直接I/O,也有read/write和pread/pwrite两组不同的API。pread/pwrite在多线程读写同一个文件的时候很有用。

内存映射文件与零拷贝

内存映射文件

相比直接I/O,内存映射文件往前更进了一步。如下下图所示,当用户空间不在有物理内存,直接拿应用程序内存地址映射到Linux操作系统的内核缓冲区,应用程序虽然读写的是自己的内存,但这个内存只是一个“逻辑地址”,实际读写的是内核缓冲区!
数据拷贝次数从缓冲I/O的3次,到直接I/O的两次,再到内存映射文件,变成了1次。
读:磁盘->内核缓冲区。
写:内核缓冲区->磁盘。
在这里插入图片描述
在Linux系统中,内存映射文件对应的API是:

void* mmap(void* start, size_t length, int prot, int flags, int fd, off_t offset);

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

零拷贝

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

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

fd1 = 打开的文件描述符
fd2 = 打开的socket描述符
buffer = 应用程序内存
read(fd1, buffer...); // 先把数据从文件中读出来
write(fd2, buffer...); // 在通过网络发出去 

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

实现方法2:利用内存映射文件,伪代码如下

fd1 = 打开的文件描述符
fd2 = 打开的socker描述符
buffer = 应用程序内存
mmap(fd1, buffer...); // 先把磁盘数据映射到buffer上
write(fd2, buffer...); // 再通过网络发出去

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

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

在这里插入图片描述
在这里,我们看到虽然叫零拷贝,实际上是2次数据拷贝,1次是磁盘到内核缓冲区,1次是内核缓冲区到网络。之所以叫零拷贝,是从内存的角度来看的,数据在内存中没有发生过数据拷贝,只是内存和I/O之间传输。

总结

对于把文件发生到网络的这个场景,直接I/O、内存映射文件、零拷贝对于的数据拷贝分别是4、3、2,内存拷贝次数分别是2、1、0次。
在linux系统中,零拷贝的系统API为:

sendfile(int out_fd, int in_fd, off_t *offset, size_t count);

其中out_fd传入是socket描述符,in_fd传入的是文件描述符。
在Java中,对应的是:

FileChannel.transferTo(long position, long count, WritableByteChannel target);

网络I/O模型

网络I/O模型存在诸多概念,有的是操作系统层面的,有的是应用框架层面的,这些概念往往容易混淆,本章将对网络I/O模型进行一次系统的梳理。在高并发部分,将把底层和上层放在一起,对“异步化”做全面的探讨。

实现层面的网络I/O模型

说到网络I/O模型,大家往往会混淆阻塞和非阻塞、同步和异步这两对概念,最常见的概念混淆有三个:

  • 认为非阻塞I/O(Non-Blocking IO)和异步I/O(asynchronous IO)是同一个概念。
  • 认为Linux系统下的select、poll、epoll这里I/O多路复用是“异步I/O”。
  • 存在一种I/O模型,叫“异步非阻塞I/O”,实际没有这种模型。
    之所以会有这些概念被混淆,往往是因为大家在谈论这些概念的时候语境不一样,有的可能说的是Linux操作系统层面的模型,有的说的是Java的JDK层面的模型,有的说的是上层框架封装的模型(比如Netty,Nginx,C++的asio)。
    下面要讲的网络I/O模型,主要是“Linux系统”语境。
    第一种模型:同步阻塞I/O。
    这种很简单,就是Linux系统的read和write函数,在调用的时候会被阻塞,直到数据读取完成,或者写入成功。
    第二种模型:同步非阻塞I/O。
    和同步阻塞I/O的API是一样的,只是打开fd的时候带有O_NONBLOCK参数。于是,当调用read和write函数的时候,如果没有准备好数据,会立即返回,不会阻塞,然后让应用程序不断的去轮询。
    第三种模型:I/O多路复用(IO Multiplexing)。

前面两种I/O都只能用于简单的客户端开发。但对于服务器程序来说,需要处理很多的fd(连接数可以达到几十万甚至百万)。如果使用同步阻塞I/O,要处理这么多的fd需要开非常多的线程,每个线程处理一个fd;如果用同步非阻塞I/O,要应用程序轮询这么大规模的fd。这两种办法都不行,所以就有了I/O多路复用。
在Linux系统中,有三种I/O多路复用的办法:select、poll、epoll,他们的原理有一定差异,后面会专门分析。这里先以select为例介绍其用法:

int select(int maxfdp1, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, ...);

该函数是阻塞调用,一次性把所有的fd传进去,当有fd可读或者可写之后,该函数会返回,返回结果也在这个函数的参数里面,告知应用程序哪些fd上面可读或者可写,然后下一步应用程序调用read和write函数进行数据读写。
I/O多路复用是现在Linux系统上最成熟的网络I/O模型,在三种方式中,epoll的效率最高,所以目前主流的网络模型都是epoll。

第四种模型:异步I/O。
熟悉Windows系统开发的人会知道Windows系统的IOCP,这是一种真正意义上的异步I/O。所谓异步I/O,是指读写都是由操作系统完成的,然后通过回调函数或者某种其他通信机制通知应用程序。
这样说可能还不太容易理解,下面看一个异步I/O的例子:C++中的asio网络库。asio是一个跨平台的C++网络库,也是boost的一部分,在Linux系统上封装的是epoll,在windows系统上封装的是IOCP。asio的接口是完全异步的,如下面的样例代码所示:

asio::async_read(socket_,
  asio::buffer(read_msg_.data(), chat_message::header_length),
  boost::bind(&chat_session::handler_read_header, shared_from_this(),
  asio::placeholders::error));

asio::async_write(socket_,
  asio::buffer(write_msgs_.front().data(),write_msgs_.front().length()),
  boost::bind(&chat_session::handler_write, shared_from_this(),
  asio::placeholders::error));

async_read/async_write函数传进去的参数主要有三个:

  • socket.
  • 应用程序的buffer。
  • 回调函数。在上面的代码中,分别是chat_session这个类的两个成员函数handle_read_header/handle_write。
    应用程序调用了这两个函数后都会立即返回,由asio库内部进行I/O读写。读写完成后通过传入的回调函数通知应用程序,读写已经完成。
    当然,asio是一个上层框架层的“异步I/O”,或者说是模拟出来的“异步I/O”,在Linux系统上还是有epoll实现的。举这个例子主要是想说明所谓“异步”,就是读写有底层完成(操作系统或者框架),读写完成之后,以某种方式通知应用程序。
    在Linux系统上,也有异步I/O的实现,就是aio。但由于aio并不成熟,所以现在主要还是用epoll。
    介绍完4种I/O模型之后,下面对阻塞和非阻塞、同步和异步做一个总结,如下所示:
    在这里插入图片描述
  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

int select(int maxfdp1, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);

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

  • 因为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

int poll(struct pollfd *fds, unsigned int nfds, int timeout);
struct pollfd {
	int fd;
	// 每个fd,两个bit数组,一个进去,一个出来的
	short events;
	short revent;
}

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

epoll

// 创建一个epoll的句柄,size用来告诉内核监听的数目一共有多少。其中的size并不要求是准确的数据,只是告诉内核,计划监听多少个fd。实际通过epoll_ctl添加的fd数目可能大于这个值。
int epoll_create(int size);
// 将一个fd增/删/改到epfd里,对应的事件也即读/写
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
// 其中的maxevents也是可以自定义的。假如有100个fd,而maxevents只是设置为64,则其他fd上面的事件会在下次epoll_wait时返回
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);

整个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的个数根据上层决定,通常有几百个。
在这里插入图片描述

  • 监听线程
    负责accept事件的注册和处理。和每一个新进来的客户端建立socket连接,然后把socket移交给I/O线程,完成任务,继续监听新的客户端端。
  • I/O线程
    负责每个socket连接上面read/write事件的注册、监听和实际socket的读写。把读到的Request放入Request队列,交由Worker线程处理。
  • worker线程
    纯粹的业务线程,没有socket读写操作。对Request队列进行处理,生成Response队列,然后写入Response队列,有I/O线程再回复给客户端。
    上图只是展示了1+N+M的一种实现方式,实际上不同系统的实现方式会有些差异,下图展示了Tomcat6的NIO网络模型。
    在这里插入图片描述
    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是一个本地方法。

public final boolean compareAndSet(int expect, int update) {
  return unsafe.compareAndSWapInt(this, valueOffset, expect, update);
}
public final native boolean compareAndSwapInt(Object o, long offset, int expected, int x);

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

参考

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

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

融极

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值