4.软件架构设计:大型网站技术架构与业务架构融合之道 --- 操作系统

Posted enlyhua

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了4.软件架构设计:大型网站技术架构与业务架构融合之道 --- 操作系统相关的知识,希望对你有一定的参考价值。

第4章 操作系统 
4.1 缓冲I/O和直接I/O 
	缓冲IO:缓冲IO是C语言提供的库函数,均以f打头;
		fopen,fclose,fseek,fflush,fread,fwrite,fprintf,fscanf;

	直接IO:是Linux系统的API,操作系统的API也是C语言写的;
		open,close,lseek,fsync,read,write,pread,pwrite;

	应用程序内存:是通常写代码用 malloc/free,new/delete 等分配出来的内存;
	用户缓冲区:C语言FILE结构体里面 buffer;
	内核缓冲区:Linux操作系统的Page Cache。为了加快磁盘IO,Linux系统会把磁盘上的数据以 Page为单位在操作系统的内存里,这里的Page是Linux
			  系统定义的一个逻辑概念,一般一个Page为4k。

	对于缓冲IO,一般读操作会有3次数据拷贝,一个写操作,有反向的3次数据拷贝;
		读:磁盘 => 内核缓冲区 => 用户缓冲区 => 应用程序内存;
		写:应用程序内存 => 用户缓冲区 => 内核缓冲区 => 磁盘;

	对于直接IO:一个读操作,会有2次数据拷贝,一个写操作,有反向的2次数据拷贝;
		读:磁盘 => 内核缓冲区 => 应用程序内存;
		写:应用程序内存 => 内核缓冲区 => 磁盘。

	所谓的直接IO,其中直接的意思是指没有用户级缓冲,但操作系统本身的缓冲还是有的。

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

4.2 内存映射文件与零拷贝 
	4.2.1 内存映射文件 
		相比于直接IO,内存映射文件更进一步了。当用户空间不再有物理内存,直接拿应用程序的逻辑内存映射到Linux操作系统的内核缓冲区,应用程序
	虽然读写的是自己的内存,但这个内存只是一个"逻辑地址",实际读写的是内核缓冲区。

		数据拷贝从缓冲IO的3次,到直接IO的2次,再到内存映射文件的1次。
			读:磁盘 => 内核缓冲区
			写:内核缓冲区 => 磁盘

		在Linux中,内存映射文件对应的系统API是:mmap()

	4.2.2 零拷贝 
		当用户需要把文件中的数据发送到网络的时候,如果不用零拷贝,实现方式有:

			实现方法1:利用直接IO,伪代码如下:
				fd1 = 打开的文件描述符
				fd2 = 打开的socket描述符
				buffer = 应用程序内存
				read(fd1,buffer ...) // 先把数据从文件中读取出来
				write(fd2,buffer ...) //再通过网络发送出去

			整个过程会有4次数据拷贝,读进来2次,写回去2次。
			磁盘 => 内核缓冲区 => 应用程序内存 => Socket缓冲区 => 网络

			实现方法2:利用内存映射文件,伪代码如下:
				fd1 = 打开的文件描述符
				fd2 = 打开的socket描述符
				buffer = 应用程序内存
				mmap(fd1,buffer ...) //先把磁盘数据映射到buffer上
				write(fd2,buffer ...) //再通过网络发送出去

			整个过程会有3次数据拷贝,不再经过应用程序内存,直接在内核空间中从内核缓冲区拷贝到socket缓冲区。

			实现方法3:零拷贝
			如果使用零拷贝,可能连内核缓冲区到socket缓冲区的拷贝也省略了。在内核缓冲区和socket缓冲区之间并没有做数据的拷贝,只是一个
		地址的映射,底层的网卡驱动程序要读取数据并发送到网络的时候,看似读的是socket缓冲区的数据,实际上直接读的是内核缓冲区的数据。

			在这里,虽然叫零拷贝,实际是2次数据拷贝,1次是从磁盘到内核缓冲区,1次是从内核缓冲区到网络。之所以叫零拷贝,是从内存的角度来看,
		数据在内存中没有发生过数据拷贝,只是在内存和IO之间传输。

			在Linux系统中,API是:sendfile()。


4.3 网络I/O模型 
	4.3.1 实现层面的网络I/O模型 
		Linux 语境下面的IO模型:
			1. 同步阻塞IO
				就是Linux的read和write函数,在调用的时候会被阻塞,直到数据读取完成,或者写入成功。

			2. 同步非阻塞IO
				和同步阻塞IO的API是一样的,只是打开fd的时候会带有 O_NONBLOCK 参数。于是,当调用read和write函数的时候,如果没有准备好
			数据,会立即返回,不会阻塞,然后让应用程序不断的去轮询。

			3. IO多路复用
				前面两种IO都只能用于简单的客户端开发。但对服务器程序来说,需要处理很多的fd(连接数可以达到几十万甚至百万)。如果都使用同步
			阻塞IO,要处理这么多fd需要非常多的线程,每一个线程处理一个fd;如果使用非阻塞IO,要应用程序轮询这么大规模的fd。这两种办法都
			不行,于是有了IO多路复用。

				在Linux中,有三种IO多路复用的办法:select,poll,epoll。如 select(),该函数是阻塞调用,一次性把所有的fd传进去,当有fd
			可读或者可写的时候,该函数会返回,返回结果也在这个函数的参数里面,告知应用程序哪些fd上面可读可写,然后下一步应用程序调用read和
			write函数进行数据读写。

			4. 异步IO
				windows的异步IO是 IOCP。所谓异步IO是指读写都是由操作系统完成的,然后通过回调函数或者某种其他通信机制通知应用程序。

		总结:
			1.阻塞和非阻塞是从函数调用的角度来说的,而同步和异步是从"读写是谁完成的"角度来说的。
				阻塞:如果读写没有就绪或者读写没有完成,则该函数一直等待。
				非阻塞:函数立即返回,然后应用程序轮询。
				同步:读写由应用程序完成。
				异步:读写由操作系统完成,完成之后,通过回调或者事件通知应用程序。

			2.按照这个定义可以知道,异步IO一定是非阻塞IO,不存在既是异步IO,又是阻塞IO的;同步IO可能是阻塞的,也可能是非阻塞的。归类后
			总共有3种:同步阻塞IO,同步非阻塞IO,异步IO。

			3.IO多路复用(select,poll,epoll)都是同步IO,因为read和write函数都是应用程序完成的,同时也是阻塞IO,因为select,poll,
			epoll的调用都是阻塞的。

			所以,当讲网络IO模型的时候,一定要注意讲的是操作系统层面的IO模型,还是上层的网络框架封装出来的IO模型(如asio,如Java的NIO,
		在Linux平台,底层也是基于epoll)。

			另外,对于异步IO一词,在操作系统的语境和上层应用的语境中,往往指代不一样。在操作系统的语境中,异步IO是指IOCP或者aio这种真正
		的异步IO,epoll不被认为是异步IO;但在上层语境中,异步IO往往是指 JavaJDK或者网络框架(Netty)封装出来的概念,底层实现可能
		epoll,也可能是真正的异步IO。

	4.3.2 Reactor模式与Preactor模式 
		Reactor模式与Preactor模式,它们是网络框架的两种设计模式。
			1.Reactor模式。主动模式。所谓主动模式,是指应用程序不断的轮询,询问操作系统或者网络框架,IO是否准备就绪。在Linux系统下的
			select,poll,epoll就属于主动模式,需要应用程序中有一个循环一直轮询;Java中的NIO也属于这种模式。在这种模式下,实际的IO
			操作还是应用程序执行。

			2.Proactor模式。被动模式。应用程序把read和write函数操作全部交给操作系统或者网络框架,实际的IO操作由操作系统或者网络框架
			完成,之后再回调应用程序。asio库就是典型的Proactor模式。

	4.3.3 select、epoll的LT与ET 
		1.select 
			int select(int maxfdp1, fd_set* readfds, fd_set writefds, fd_set* execptfds, struct timeval* timeout);

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

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

			从上面可以看出来,select,poll 每次调用都需要应用程序把fd的数组传进去,这个fd的数组每次都要在用户态和内核态之间传递,影响
		效率。为了,epoll设计了"逻辑上的epfd"。epfd是一个数字,把fd数组关联到上面,然后每次向内核传递的是epfd这个数字。

		3.epoll
			//创建一个epoll句柄,size告诉内核监听的数目一共有多少。其中的size并不要求是准确的数字,只是告诉内核,计划监听多少个fd。实际
			//通过epoll_ctl添加的fd数目可能大于这个值。
			int epoll_create(int sieze);

			//将一个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.轮询这3个事件是否就绪
					通过函数epoll_wait实现。有事件发生,该函数就返回。

				3.事件就绪,执行实际的IO操作
					通过函数 accept/read/write实现。

			事件就绪:
				1.read事件就绪
					远程有新数据来了,socket读写缓存区里有数据,需要调用read函数处理。

				2.write事件就绪
					是指本地的socket写缓冲区是否可写。如果写缓冲区没有满,则一直是可写的,write事件是就绪的,可以调用write函数。只有
				当遇到发送大文件的场景,socket写缓冲区被占满时,write事件才不是就绪的状态。

				3.accept事件就绪
					有新的连接进入时,需要调用accept函数处理。

		4.epoll的ET和LT模式
			epoll里面有两种模式:LT(水平触发)和ET(边缘触发)。水平触发又称为条件触发,边缘触发又称为状态触发。

			水平触发:
				读缓冲区主要不为空,就会一直触发读事件;写缓冲区只要不满,就会一直触发写事件。

			边缘触发:
				读缓冲区的状态,从空转为非空的时候触发一次;写缓冲区的状态,从满转为非满的时候,触发一次。比如用户发送一个大文件,把
			写缓冲区塞满后,之后缓冲区可以写了,就会发生一次从满到不满的切换。

			关于LT和ET,有两种需要注意的问题:
				1.对于LT模式,要避免"写的死循环"问题:写缓冲区为满的概率很小,即"写的条件"会一直满足,所以当用户注册了写事件却没有数据要写
				的时候,它会一直触发,因此在LT模式下写完数据一定要取消写事件。
				2.对于ET模式,"要避免 short read"问题。例如用户收到100个字节,它触发了1次,但用户只读取了50个字节,剩下的50个不读,它
				也不会再次触发。因此在ET模式下,一定要把"读缓冲区"的数据一次性读完。

			在实际开发中,一般倾向于用LT,这也是默认的模式。因为ET容易漏事件,一次触发如果没有处理好,就没有第二次机会。虽然LT重复触发看似
		有少许的性能损耗,但代码写起来更安全。

	4.3.4 服务器编程的1 N M模型 
		在服务器的编程中,epoll 的编程步骤是由不同的线程负责的,即服务器编程的 1+N+M模型。

		整个服务器有 1+N+M个线程,1个监听线程,N个IO线程,M个Worker线程。N的个数通常等于cpu核数,M的个数更具上层决定,通常有几百个。
			1.监听线程
				负责accept事件的注册和处理。和每一个新来的客户端建立socket连接,然后把socket连接转交给IO线程,完成任务,继续监听新的。

			2.IO线程
				负责每个socket连接上面read/write事件的注册和实际的socket读写。把读到的Request放入Request队列,交由Worker线程处理。

			3.Worker线程
				纯粹的业务线程,没有socket读写。对Request队列进行处理,生成Response队列,然后写入Response队列,由IO线程再回复给客户端。

		Tomcat6的NIO网络模型:
			IO线程只负责read/write事件的注册和监听,执行了epoll里面的前面2个阶段,第三个阶段是在Worker线程里面做的。IO线程监听到一个
		socket连接上有读事件,于是把socket转交给Worker线程,worker线程读出数据,处理完业务逻辑,直接返回给客户端。之所以可以这么做,是
		因为IO线程已经检测到读事件就绪,所以当worker线程在读的时候不会等待。IO线程和worker线程之间交互,不再需要一来一回两个队列,直接
		是一个socket集合。

4.4 进程、线程和协程 
	用Java的人通常写的是"单进程多线程"的程序;而用C++的人,可能写的是"单进程多线程","多进程单线程","多进程多线程"的程序。之所以会有这样的
差异,是因为Java程序并不是直接跑在Linux系统上的,而是运行在JVM之上,而一个JVM实例是Linux进程,每一个JVM都是一个独立的"沙盒",JVM之间互相
独立,互不通信。所以Java程序只能在这一个进程里面,开多个线程开发。而C++直接运行在Linux系统上,可以利用Linux系统提供的强大的进程间的通信机制
(IPC),很容易创造出多个进程,并实现进程间的通信。
	
	1.为什么要多线程
		多线程主要是为了应对IO密集型的应用。多线程能带来两方面的好处:
			1.提供cpu利用率
			2.提供IO吞吐

	2.多进程
		多线程存在两个问题:
			1.线程间内存共享,要加线程锁;而加锁后会导致并发效率下降,同时复杂的加锁机制也导致将增加编码的难度;
			2.过多的线程会造成线程间的上下文切换,导致效率低下。

		在并发编程领域,有一个很重要的原则:"不要通过共享内存来实现通信,而应通过通信实现共享内存"。通俗点就是:"尽可能通过消息通信,而不是
	共享内存来实现进程或线程之间的同步"。

		进程是资源分配的基本单位,进程间不共享资源,通过管道或者socket方式通信(当然也可以共享内存),这种通信方式天生符合上面的并发设计原
	则。而对于多线程,大家习惯于共享内存,然后通过加各种锁来实现同步。

		除了锁的问题,多进程来带来的另外2个好处是:
			1.一是减少了多线程在不同的cpu核间切换的开销;
			2.多进程互相独立,意味着一个崩溃了,其他进程可以继续运行。

		有了多进程之后,每个进程内部,可能是单线程,也可能是多线程,这往往取决于IO。

		对于IO密集型的应用,要提高IO效率,则需要下面几种办法:
			1.异步IO
				异步化后,请求可以Pipeline处理,就不需要多线程了。但像mysql的JDBC提供的都是同步接口,不支持异步IO。

			2.多线程
				IO不支持异步,就只能开多个线程,每个线程都同步的调用IO,实际上是用多线程模拟了异步IO。如web应用服务器调用redis/mysql。

			3.多协程

	3.多协程
		多线程除锁之外,还有一个问题是线程太多,切换的开销很大。虽然线程切换的开销比进程切换的开销已经小了很多,但还是不够。以
	  tomcat为例,在通常配置的服务器最多只能开几百个线程。如果再多,则线程切换的开销太大,并发效率反而会下降。这意味着tomcat最多
	  能并发的接受几百个请求。但如果是协程的话,可以开几万个。协程相比线程有两个关键特点:
		a) 更好的利用cpu。线程的调度是操作系统完成的,应用程序干预不了,协程可以由应用程序自己调度。
		b) 更好的利用内存。协程的堆栈大小不是固定的,用多少申请多少,内存利用率更高。

4.5 无锁(内存屏障与CAS) 
	4.5.1 内存屏障 
		Linux 内核的 kfifo.c 源码实现了无锁。核心的要点是:
			1.读可以多线程,写必须是单线程,也称为 Single-Writer Principle。如果是多线程写,则做不到无锁。
			2.在上面的基础上,使用了内存屏障。也就是 smp_wmb()调用。从用法来说,内存屏障是在两行代码之间插入一个栅栏,如下所示:
				代码第1行
				代码第2行
					内存屏障
				代码第3行
				代码第4行

			在第2行代码和第3行代码之间插入一个内存屏障,这样前2行代码就不会跑到后2行代码的后面执行。虽然第1行,第2行之间可能被重排序;
		第3行和第4行可能被重排序,但第1行,第2行不会跑到第3行后面去。

			所谓的重排序,通俗的讲,就是cpu不会按照开发者的代码顺序来执行。

		基于内存屏障,有了Java中的 volatile 关键字,再加上单线程写的原则,就有了Java中无锁并发框架 --- Disruptor,其核心就是 "
	一写多读,完全无锁"。

	4.5.2 CAS 
		如果是多线程写,则内存屏障也不够用了,这时要用到CAS。CAS是cpu层面提供的一个硬件原子指令,实现对同一个值的Compare和Set两个
	操作的原子化。

		基于CAS,上层可以实现 乐观锁,无锁队列,无锁栈,无锁链表。

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

以上是关于4.软件架构设计:大型网站技术架构与业务架构融合之道 --- 操作系统的主要内容,如果未能解决你的问题,请参考以下文章

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

17.软件架构设计:大型网站技术架构与业务架构融合之道 --- 团队能力的提升

3.软件架构设计:大型网站技术架构与业务架构融合之道 --- 语言

14.软件架构设计:大型网站技术架构与业务架构融合之道 --- 业务架构思维

13.软件架构设计:大型网站技术架构与业务架构融合之道 --- 业务意识

7.软件架构设计:大型网站技术架构与业务架构融合之道 --- 框架软件与中间件