first commit
This commit is contained in:
168
专栏/重学操作系统-完/14用户态和内核态:用户态线程和内核态线程有什么区别?.md
Normal file
168
专栏/重学操作系统-完/14用户态和内核态:用户态线程和内核态线程有什么区别?.md
Normal file
@@ -0,0 +1,168 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
14 用户态和内核态:用户态线程和内核态线程有什么区别?
|
||||
这节课给你带来了一道非常经典的面试题目:用户态线程和内核态线程有什么区别?
|
||||
|
||||
这是一个组合型的问题,由很多小问题组装而成,比如:
|
||||
|
||||
|
||||
用户态和内核态是什么?
|
||||
用户级线程和内核级线程是一个怎样的对应关系?
|
||||
内核响应系统调用是一个怎样的过程?
|
||||
……
|
||||
|
||||
|
||||
而且这个问题还关联到了我们后面要学习的多线程、I/O 模型、网络优化等。 所以这是一道很不错的面试题目,它不是简单考某个概念,而是通过让求职者比较两种东西,从而考察你对知识整体的认知和理解。
|
||||
|
||||
今天就请你顺着这个问题,深入学习内核的工作机制,和我一起去理解用户态和内核态。
|
||||
|
||||
什么是用户态和内核态
|
||||
|
||||
Kernel 运行在超级权限模式(Supervisor Mode)下,所以拥有很高的权限。按照权限管理的原则,多数应用程序应该运行在最小权限下。因此,很多操作系统,将内存分成了两个区域:
|
||||
|
||||
|
||||
内核空间(Kernal Space),这个空间只有内核程序可以访问;
|
||||
用户空间(User Space),这部分内存专门给应用程序使用。
|
||||
|
||||
|
||||
用户态和内核态
|
||||
|
||||
用户空间中的代码被限制了只能使用一个局部的内存空间,我们说这些程序在用户态(User Mode) 执行。内核空间中的代码可以访问所有内存,我们称这些程序在内核态(Kernal Mode) 执行。
|
||||
|
||||
系统调用过程
|
||||
|
||||
如果用户态程序需要执行系统调用,就需要切换到内核态执行。下面我们来讲讲这个过程的原理。
|
||||
|
||||
|
||||
|
||||
如上图所示:内核程序执行在内核态(Kernal Mode),用户程序执行在用户态(User Mode)。当发生系统调用时,用户态的程序发起系统调用。因为系统调用中牵扯特权指令,用户态程序权限不足,因此会中断执行,也就是 Trap(Trap 是一种中断)。
|
||||
|
||||
发生中断后,当前 CPU 执行的程序会中断,跳转到中断处理程序。内核程序开始执行,也就是开始处理系统调用。内核处理完成后,主动触发 Trap,这样会再次发生中断,切换回用户态工作。关于中断,我们将在“15 课时”进行详细讨论。
|
||||
|
||||
线程模型
|
||||
|
||||
上面我们学习了用户态和内核态,接下来我们从进程和线程的角度进一步思考本课时开头抛出的问题。
|
||||
|
||||
进程和线程
|
||||
|
||||
一个应用程序启动后会在内存中创建一个执行副本,这就是进程。Linux 的内核是一个 Monolithic Kernel(宏内核),因此可以看作一个进程。也就是开机的时候,磁盘的内核镜像被导入内存作为一个执行副本,成为内核进程。
|
||||
|
||||
进程可以分成用户态进程和内核态进程两类。用户态进程通常是应用程序的副本,内核态进程就是内核本身的进程。如果用户态进程需要申请资源,比如内存,可以通过系统调用向内核申请。
|
||||
|
||||
那么用户态进程如果要执行程序,是否也要向内核申请呢?
|
||||
|
||||
程序在现代操作系统中并不是以进程为单位在执行,而是以一种轻量级进程(Light Weighted Process),也称作线程(Thread)的形式执行。
|
||||
|
||||
一个进程可以拥有多个线程。进程创建的时候,一般会有一个主线程随着进程创建而创建。
|
||||
|
||||
|
||||
|
||||
如果进程想要创造更多的线程,就需要思考一件事情,这个线程创建在用户态还是内核态。
|
||||
|
||||
你可能会问,难道不是用户态的进程创建用户态的线程,内核态的进程创建内核态的线程吗?
|
||||
|
||||
其实不是,进程可以通过 API 创建用户态的线程,也可以通过系统调用创建内核态的线程,接下来我们说说用户态的线程和内核态的线程。
|
||||
|
||||
用户态线程
|
||||
|
||||
用户态线程也称作用户级线程(User Level Thread)。操作系统内核并不知道它的存在,它完全是在用户空间中创建。
|
||||
|
||||
用户级线程有很多优势,比如。
|
||||
|
||||
|
||||
管理开销小:创建、销毁不需要系统调用。
|
||||
切换成本低:用户空间程序可以自己维护,不需要走操作系统调度。
|
||||
|
||||
|
||||
但是这种线程也有很多的缺点。
|
||||
|
||||
|
||||
与内核协作成本高:比如这种线程完全是用户空间程序在管理,当它进行 I/O 的时候,无法利用到内核的优势,需要频繁进行用户态到内核态的切换。
|
||||
线程间协作成本高:设想两个线程需要通信,通信需要 I/O,I/O 需要系统调用,因此用户态线程需要支付额外的系统调用成本。
|
||||
无法利用多核优势:比如操作系统调度的仍然是这个线程所属的进程,所以无论每次一个进程有多少用户态的线程,都只能并发执行一个线程,因此一个进程的多个线程无法利用多核的优势。
|
||||
操作系统无法针对线程调度进行优化:当一个进程的一个用户态线程阻塞(Block)了,操作系统无法及时发现和处理阻塞问题,它不会更换执行其他线程,从而造成资源浪费。
|
||||
|
||||
|
||||
内核态线程
|
||||
|
||||
内核态线程也称作内核级线程(Kernel Level Thread)。这种线程执行在内核态,可以通过系统调用创造一个内核级线程。
|
||||
|
||||
内核级线程有很多优势。
|
||||
|
||||
|
||||
可以利用多核 CPU 优势:内核拥有较高权限,因此可以在多个 CPU 核心上执行内核线程。
|
||||
操作系统级优化:内核中的线程操作 I/O 不需要进行系统调用;一个内核线程阻塞了,可以立即让另一个执行。
|
||||
|
||||
|
||||
当然内核线程也有一些缺点。
|
||||
|
||||
|
||||
创建成本高:创建的时候需要系统调用,也就是切换到内核态。
|
||||
扩展性差:由一个内核程序管理,不可能数量太多。
|
||||
切换成本较高:切换的时候,也同样存在需要内核操作,需要切换内核态。
|
||||
|
||||
|
||||
用户态线程和内核态线程之间的映射关系
|
||||
|
||||
线程简单理解,就是要执行一段程序。程序不会自发的执行,需要操作系统进行调度。我们思考这样一个问题,如果有一个用户态的进程,它下面有多个线程。如果这个进程想要执行下面的某一个线程,应该如何做呢?
|
||||
|
||||
这时,比较常见的一种方式,就是将需要执行的程序,让一个内核线程去执行。毕竟,内核线程是真正的线程。因为它会分配到 CPU 的执行资源。
|
||||
|
||||
如果一个进程所有的线程都要自己调度,相当于在进程的主线程中实现分时算法调度每一个线程,也就是所有线程都用操作系统分配给主线程的时间片段执行。这种做法,相当于操作系统调度进程的主线程;进程的主线程进行二级调度,调度自己内部的线程。
|
||||
|
||||
这样操作劣势非常明显,比如无法利用多核优势,每个线程调度分配到的时间较少,而且这种线程在阻塞场景下会直接交出整个进程的执行权限。
|
||||
|
||||
由此可见,用户态线程创建成本低,问题明显,不可以利用多核。内核态线程,创建成本高,可以利用多核,切换速度慢。因此通常我们会在内核中预先创建一些线程,并反复利用这些线程。这样,用户态线程和内核态线程之间就构成了下面 4 种可能的关系:
|
||||
|
||||
多对一(Many to One)
|
||||
|
||||
用户态进程中的多线程复用一个内核态线程。这样,极大地减少了创建内核态线程的成本,但是线程不可以并发。因此,这种模型现在基本上用的很少。我再多说一句,这里你可能会有疑问,比如:用户态线程怎么用内核态线程执行程序?
|
||||
|
||||
程序是存储在内存中的指令,用户态线程是可以准备好程序让内核态线程执行的。后面的几种方式也是利用这样的方法。
|
||||
|
||||
|
||||
|
||||
一对一(One to One)
|
||||
|
||||
该模型为每个用户态的线程分配一个单独的内核态线程,在这种情况下,每个用户态都需要通过系统调用创建一个绑定的内核线程,并附加在上面执行。 这种模型允许所有线程并发执行,能够充分利用多核优势,Windows NT 内核采取的就是这种模型。但是因为线程较多,对内核调度的压力会明显增加。
|
||||
|
||||
|
||||
|
||||
多对多(Many To Many)
|
||||
|
||||
这种模式下会为 n 个用户态线程分配 m 个内核态线程。m 通常可以小于 n。一种可行的策略是将 m 设置为核数。这种多对多的关系,减少了内核线程,同时也保证了多核心并发。Linux 目前采用的就是该模型。
|
||||
|
||||
|
||||
|
||||
两层设计(Two Level)
|
||||
|
||||
这种模型混合了多对多和一对一的特点。多数用户态线程和内核线程是 n 对 m 的关系,少量用户线程可以指定成 1 对 1 的关系。
|
||||
|
||||
|
||||
|
||||
上图所展现的是一个非常经典的设计。
|
||||
|
||||
我们这节课讲解的问题、考虑到的情况以及解决方法,将为你今后解决实际工作场景中的问题打下坚实的基础。比如处理并发问题、I/O 性能瓶颈、思考数据库连接池的配置等,要想完美地解决问题,就必须掌握这些模型,了解问题的本质上才能更好地思考问题衍生出来的问题。
|
||||
|
||||
总结
|
||||
|
||||
这节课我们学习了用户态和内核态,然后我们简单学习了进程和线程的基础知识。这部分知识会在“模块四:进程和线程”中以更细粒度进行详细讲解。等你完成模块四的学习后,可以再返回来看这一节的内容,相信会有更深入的理解。
|
||||
|
||||
最后,我们还讨论了用户线程和内核线程的映射关系,这是一种非常经典的设计和思考方式。关于这个场景我们讨论了 1 对 1、1 对多以及多对 1,两层模型 4 种方法。日后你在处理线程池对接;远程 RPC 调用;消息队列时,还会反复用到今天的方法。
|
||||
|
||||
那么通过这节课的学习,你现在是否可以来回答本节关联的面试题目?用户态线程和内核态线程的区别?
|
||||
|
||||
老规矩,请你先在脑海里构思下给面试官的表述,并把你的思考写在留言区,然后再来看我接下来的分析。
|
||||
|
||||
【解析】 用户态线程工作在用户空间,内核态线程工作在内核空间。用户态线程调度完全由进程负责,通常就是由进程的主线程负责。相当于进程主线程的延展,使用的是操作系统分配给进程主线程的时间片段。内核线程由内核维护,由操作系统调度。
|
||||
|
||||
用户态线程无法跨核心,一个进程的多个用户态线程不能并发,阻塞一个用户态线程会导致进程的主线程阻塞,直接交出执行权限。这些都是用户态线程的劣势。内核线程可以独立执行,操作系统会分配时间片段。因此内核态线程更完整,也称作轻量级进程。内核态线程创建成本高,切换成本高,创建太多还会给调度算法增加压力,因此不会太多。
|
||||
|
||||
实际操作中,往往结合两者优势,将用户态线程附着在内核态线程中执行。
|
||||
|
||||
|
||||
|
||||
|
162
专栏/重学操作系统-完/15中断和中断向量:Javajs等语言为什么可以捕获到键盘输入?.md
Normal file
162
专栏/重学操作系统-完/15中断和中断向量:Javajs等语言为什么可以捕获到键盘输入?.md
Normal file
@@ -0,0 +1,162 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
15 中断和中断向量:Javajs 等语言为什么可以捕获到键盘输入?
|
||||
你好,发现求知的乐趣,我是林䭽。
|
||||
|
||||
本课时我们依然以一道面试题为引开启今天的学习。请你思考:Java/JS 等语言为什么可以捕获到键盘的输入?
|
||||
|
||||
其实面试是一个寻找同类的过程,在阿里叫作“闻味道”——用键盘输入是程序员每天必做的事情,如果你对每天发生的事情背后的技术原理保持好奇心和兴趣,并且愿意花时间去探索和学习,这就是技术潜力强的表现。相反,如果你只对马上能为自己创造价值的事情感兴趣,不愿意通过探索和思考的方式,去理解普遍存在的世界,长此以往就会导致知识储备不足。
|
||||
|
||||
我想通过本课时讲解一种特别的学习技巧,可以说是“填鸭式学习”的反义词,叫作“探索式学习”。我看网上也叫作“破案式学习”,学习过程像攻破一个谜题,或者分析一个案件,并不是从结论开始,然后一层层学习理论;而是通过找到一个目标,一层层挖掘需要的知识、理论,一点点去思考解决方案,最终达到提升解决问题能力的目的。
|
||||
|
||||
接下来,请你和我一起化身成一名计算机科学家,假设明天就要生产机器了,但是为 Java/JS 等语言提供键盘输入支持模块的操作系统今天还没有完成,现在还有一节课的时间,那么我们应该如何去做呢?
|
||||
|
||||
探索过程:如何设计响应键盘的整个链路?
|
||||
|
||||
当你拿到一个问题时,需要冷静下来思考和探索解决方案。你可以查资料、看视频或者咨询专家,但是在这之前,你先要进行一定的思考和梳理,有的问题可以直接找到答案,有的问题却需要继续深挖寻找其背后的理论支撑。
|
||||
|
||||
问题 1:我们的目标是什么?
|
||||
|
||||
我们的目标是在 Java/JS 中实现按键响应程序。这种实现有点像 Switch-Case 语句——根据不同的按键执行不同的程序,比如按下回车键可以换行,按下左右键可以移动光标。
|
||||
|
||||
问题 2:按键怎么抽象?
|
||||
|
||||
键盘上一般不超过 100 个键。因此我们可以考虑用一个 Byte 的数据来描述用户按下了什么键。按键有两个操作,一个是按下、一个是释放,这是两个不同的操作。对于一个 8 位的字节,可以考虑用最高位的 1 来描述按下还是释放的状态,然后后面的 7 位(0~127)描述具体按了哪个键。这样我们只要确定了用户按键/释放的顺序,对我们的系统来说,就不会有歧义。
|
||||
|
||||
问题 3:如何处理按键?使用操作系统处理还是让每个程序自己实现?
|
||||
|
||||
处理按键是一个通用程序,可以考虑由操作系统先进行一部分处理,比如:
|
||||
|
||||
|
||||
用户按下了回车键,先由操作系统进行统一的封装,再把按键的编码转换为字符串Enter方便各种程序使用。
|
||||
处理组合键这种操作,由操作系统先一步进行计算比较好。因为底层只知道按键、释放,组合键必须结合时间因素判断。
|
||||
|
||||
|
||||
你可以把下面这种情况看作是一个Ctrl + C组合键,这种行为可以由操作系统进行统一处理,如下所示:
|
||||
|
||||
按下 Ctrl
|
||||
|
||||
按下 C
|
||||
|
||||
释放 Ctrl
|
||||
|
||||
释放 C
|
||||
|
||||
|
||||
问题 4:程序用什么模型响应按键?
|
||||
|
||||
当一个 Java 或者 JS 写的应用程序想要响应按键时,应该考虑消息模型。因为如果程序不停地扫描按键,会给整个系统带来很大的负担。比如程序写一个while循环去扫描有没有按键,开销会很大。 如果程序在操作系统端注册一个响应按键的函数,每次只有真的触发按键时才执行这个函数,这样就能减少开销了。
|
||||
|
||||
问题 5:处理用户按键,需不需要打断正在执行的程序?
|
||||
|
||||
从用户体验上讲,按键应该是一个高优先级的操作,比如用户按 Ctrl+C 或者 Esc 的时候,可能是因为用户想要打断当前执行的程序。即便是用户只想要输入,也应该尽可能地集中资源给到用户,因为我们不希望用户感觉到延迟。
|
||||
|
||||
如果需要考虑到程序随时会被中断,去响应其他更高优先级的情况,那么从程序执行的底层就应该支持这个行为,而且最好从硬件层面去支持,这样速度最快。 这就引出了本课时的主角——中断。具体如何处理,见下面我们关于中断部分的分析。
|
||||
|
||||
问题 6:操作系统如何知道用户按了哪个键?
|
||||
|
||||
这里有一个和问题 5 类似的问题。操作系统是不断主动触发读取键盘按键,还是每次键盘按键到来的时候都触发一段属于操作系统的程序呢?
|
||||
|
||||
显然,后者更节省效率。
|
||||
|
||||
那么谁能随时随地中断操作系统的程序? 谁有这个权限?是管理员账号吗? 当然不是,拥有这么高权限的应该是机器本身。
|
||||
|
||||
我们思考下这个模型,用户每次按键,触发一个 CPU 的能力,这个能力会中断正在执行的程序,去处理按键。那 CPU 内部是不是应该有处理按键的程序呢?这肯定不行,因为我们希望 CPU 就是用来做计算的,如果 CPU 内部有自带的程序,会把问题复杂化。这在软件设计中,叫作耦合。CPU 的工作就是专注高效的执行指令。
|
||||
|
||||
因此,每次按键,必须有一个机制通知 CPU。我们可以考虑用总线去通知 CPU,也就是主板在通知 CPU。
|
||||
|
||||
|
||||
|
||||
那么 CPU 接收到通知后,如何通知操作系统呢?CPU 只能中断正在执行的程序,然后切换到另一个需要执行的程序。说白了就是改变 PC 指针,CPU 只有这一种办法切换执行的程序。这里请你思考,是不是只有这一种方法:CPU 中断当前执行的程序,然后去执行另一个程序,才能改变 PC 指针?
|
||||
|
||||
|
||||
|
||||
接下来我们进一步思考,CPU 怎么知道 PC 指针应该设置为多少呢?是不是 CPU 知道操作系统响应按键的程序位置呢?
|
||||
|
||||
答案当然是不知道。
|
||||
|
||||
因此,我们只能控制 CPU 跳转到一个固定的位置。比如说 CPU 一收到主板的信息(某个按键被触发),CPU 就马上中断当前执行的程序,将 PC 指针设置为 0。也就是 PC 指针下一步会从内存地址 0 中读取下一条指令。当然这只是我们的一个思路,具体还需要进一步考虑。而操作系统要做的就是在这之前往内存地址 0 中写一条指令,比如说让 PC 指针跳转到自己处理按键程序的位置。
|
||||
|
||||
讲到这里,我们总结一下,CPU 要做的就是一看到中断,就改变 PC 指针(相当于中断正在执行的程序),而 PC 改变成多少,可以根据不同的类型来判断,比如按键就到 0。操作系统就要向这些具体的位置写入指令,当中断发生时,接管程序的控制权,也就是让 PC 指针指向操作系统处理按键的程序。
|
||||
|
||||
上面这个模型和实际情况还有出入,但是我们已经开始逐渐完善了。
|
||||
|
||||
问题 7:主板如何知道键盘被按下?
|
||||
|
||||
经过一层一层地深挖“如何设计响应键盘的整个链路?”这个问题,目前操作系统已经能接管按键,接下来,我们还需要思考主板如何知道有按键,并且通知 CPU。
|
||||
|
||||
你可以把键盘按键看作按下了某个开关,我们需要一个芯片将按键信息转换成具体按键的值。比如用户按下 A 键,A 键在第几行、第几列,可以看作一个电学信号。接着我们需要芯片把这个电学信号转化为具体的一个数字(一个 Byte)。转化完成后,主板就可以接收到这个数字(按键码),然后将数字写入自己的一个寄存器中,并通知 CPU。
|
||||
|
||||
为了方便 CPU 计算,CPU 接收到主板通知后,按键码会被存到一个寄存器里,这样方便处理按键的程序执行。
|
||||
|
||||
通过对以上 7 个问题的思考和分析,我们已经有了一个粗浅的设计,接下来就要开始整理思路了。
|
||||
|
||||
思路的整理:中断的设计
|
||||
|
||||
整体设计分成了 3 层,第一层是硬件设计、第二层是操作系统设计、第三层是程序语言的设计。
|
||||
|
||||
|
||||
|
||||
按键码的收集,是键盘芯片和主板的能力。主板知道有新的按键后,通知 CPU,CPU 要中断当前执行的程序,将 PC 指针跳转到一个固定的位置,我们称为一次中断(interrupt)。
|
||||
|
||||
考虑到系统中会出现各种各样的事件,我们需要根据中断类型来判断PC 指针跳转的位置,中断类型不同,PC 指针跳转的位置也可能会不同。比如按键程序、打印机就绪程序、系统异常等都需要中断,包括在“14 课时”我们学习的系统调用,也需要中断正在执行的程序,切换到内核态执行内核程序。
|
||||
|
||||
因此我们需要把不同的中断类型进行分类,这个类型叫作中断识别码。比如按键,我们可以考虑用编号 16,数字 16 就是按键中断类型的识别码。不同类型的中断发生时,CPU 需要知道 PC 指针该跳转到哪个地址,这个地址,称为中断向量(Interupt Vector)。
|
||||
|
||||
你可以考虑这样的实现:当编号 16 的中断发生时,32 位机器的 PC 指针直接跳转到内存地址 16*4 的内存位置。如果设计最多有 255 个中断,编号就是从 0~255,刚好需要 1K 的内存地址存储中断向量——这个 1K 的空间,称为中断向量表。
|
||||
|
||||
因此 CPU 接收到中断后,CPU 根据中断类型操作 PC 指针,找到中断向量。操作系统必须在这之前,修改中断向量,插入一条指令。比如操作系统在这里写一条Jump指令,将 PC 指针再次跳转到自己处理对应中断类型的程序。
|
||||
|
||||
|
||||
|
||||
操作系统接管之后,以按键程序为例,操作系统会进行一些处理,包括下面的几件事情:
|
||||
|
||||
|
||||
将按键放入一个队列,保存下来。这是因为,操作系统不能保证及时处理所有的按键,比如当按键过快时,需要先存储下来,再分时慢慢处理。
|
||||
计算组合键。可以利用按下、释放之间的时间关系。
|
||||
经过一定计算将按键抽象成消息(事件结构或对象)。
|
||||
提供 API 给应用程序,让应用程序可以监听操作系统处理后的消息。
|
||||
分发按键消息给监听按键的程序。
|
||||
|
||||
|
||||
所以程序在语言层面,比如像 Java/Node.js 这种拥有虚拟机的语言,只需要对接操作系统 API 就可以了。
|
||||
|
||||
中断的类型
|
||||
|
||||
接下来我们一起讨论下中断的分类方法:
|
||||
|
||||
|
||||
按照中断的触发方分成同步中断和异步中断;
|
||||
根据中断是否强制触发分成可屏蔽中断和不可屏蔽中断。
|
||||
|
||||
|
||||
中断可以由 CPU 指令直接触发,这种主动触发的中断,叫作同步中断。同步中断有几种情况。
|
||||
|
||||
|
||||
之前我们学习的系统调用,需要从用户态切换内核态,这种情况需要程序触发一个中断,叫作陷阱(Trap),中断触发后需要继续执行系统调用。
|
||||
还有一种同步中断情况是错误(Fault),通常是因为检测到某种错误,需要触发一个中断,中断响应结束后,会重新执行触发错误的地方,比如后面我们要学习的缺页中断。
|
||||
最后还有一种情况是程序的异常,这种情况和 Trap 类似,用于实现程序抛出的异常。
|
||||
|
||||
|
||||
另一部分中断不是由 CPU 直接触发,是因为需要响应外部的通知,比如响应键盘、鼠标等设备而触发的中断。这种中断我们称为异步中断。
|
||||
|
||||
CPU 通常都支持设置一个中断屏蔽位(一个寄存器),设置为 1 之后 CPU 暂时就不再响应中断。对于键盘鼠标输入,比如陷阱、错误、异常等情况,会被临时屏蔽。但是对于一些特别重要的中断,比如 CPU 故障导致的掉电中断,还是会正常触发。可以被屏蔽的中断我们称为可屏蔽中断,多数中断都是可屏蔽中断。
|
||||
|
||||
所以这里我们讲了两种分类方法,一种是同步中断和异步中断。另一种是可屏蔽中断和不可屏蔽中断。
|
||||
|
||||
总结
|
||||
|
||||
这节课我们通过探索式学习讨论了中断的设计。 通过一个问题,Java/JS 如何响应键盘按键,引出了 7 个问题的思考。通过探索这些问题,我们最终找到 了答案,完成了一次从硬件、内核到应用的完整设计。我想说的是,学习不是最终目的,长远来看我更希望你在学习的过程中得到成长,通过学习技能锻炼自己解决问题的能力。
|
||||
|
||||
那么通过这节课的学习,你现在可以来回答本节关联的面试题目:Java/Js 等语言为什么可以捕获到键盘输入?
|
||||
|
||||
老规矩,请你先在脑海里构思下给面试官的表述,并把你的思考写在留言区,然后再来看我接下来的分析。
|
||||
|
||||
【解析】 为了捕获到键盘输入,硬件层面需要把按键抽象成中断,中断 CPU 执行。CPU 根据中断类型找到对应的中断向量。操作系统预置了中断向量,因此发生中断后操作系统接管了程序。操作系统实现了基本解析按键的算法,将按键抽象成键盘事件,并且提供了队列存储多个按键,还提供了监听按键的 API。因此应用程序,比如 Java/Node.js 虚拟机,就可以通过调用操作系统的 API 使用键盘事件。
|
||||
|
||||
|
||||
|
||||
|
57
专栏/重学操作系统-完/16(1)加餐练习题详解(三).md
Normal file
57
专栏/重学操作系统-完/16(1)加餐练习题详解(三).md
Normal file
@@ -0,0 +1,57 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
16 (1)加餐 练习题详解(三)
|
||||
今天我会带你把《模块三:操作系统基础知识》中涉及的课后练习题,逐一讲解,并给出每个课时练习题的解题思路和答案。
|
||||
|
||||
练习题详解
|
||||
|
||||
13 | 操作系统内核:Linux 内核和 Windows 内核有什么区别?
|
||||
|
||||
【问题】 Unix 和 Mac OS 内核属于哪种类型?
|
||||
|
||||
【解析】 Unix 和 Linux 非常类似,也是宏内核。Mac OS 用的是 XNU 内核, XNU 是一种混合型内核。为了帮助你理解,我找了一张 Mac OS 的内核架构图。 如下图所示,可以看到内部是一个叫作 XNU 的宏内核。XNU 是 X is not Unix 的意思, 是一个受 Unix 影响很大的内核。
|
||||
|
||||
|
||||
|
||||
Mac OS 内核架构图
|
||||
|
||||
14 | 用户态和内核态:用户态线程和内核态线程有什么区别?
|
||||
|
||||
【问题】 JVM 的线程是用户态线程还是内核态线程?
|
||||
|
||||
【解析】 JVM 自己本身有一个线程模型。在 JDK 1.1 的时候,JVM 自己管理用户级线程。这样做缺点非常明显,操作系统只调度内核级线程,用户级线程相当于基于操作系统分配到进程主线程的时间片,再次拆分,因此无法利用多核特性。
|
||||
|
||||
为了解决这个问题,后来 Java 改用线程映射模型,因此,需要操作系统支持。在 Windows 上是 1 对 1 的模型,在 Linux 上是 n 对 m 的模型。顺便说一句,Linux 的PThreadAPI 创建的是用户级线程,如果 Linux 要创建内核级线程有KThreadAPI。映射关系是操作系统自动完成的,用户不需要管。
|
||||
|
||||
15 | 中断和中断向量:Java/JS 等语言为什么可以捕获到键盘输入?
|
||||
|
||||
【问题】 操作系统可以处理键盘按键,这很好理解,但是在开机的时候系统还没有载入内存,为什么可以使用键盘呢?这个怎么解释?
|
||||
|
||||
【解析】 主板的一块 ROM 上往往还有一个简化版的操作系统,叫 BIOS(Basic Input/Ouput System)。在 OS 还没有接管计算机前,先由 BIOS 管理机器,并协助加载 OS 到内存。早期的 OS 还会利用 BIOS 的能力,现代的 OS 接管后,就会替换掉 BIOS 的中断向量。
|
||||
|
||||
16 | Win/Mac/Unix/Linux 的区别和联系:为什么 Debian 漏洞排名第一还这么多人用?
|
||||
|
||||
【问题】 林纳斯 21 岁写出 Linux,那么开发一个操作系统的难度到底大不大?
|
||||
|
||||
【解析】 毫无疑问能在 21 岁就写出 Linux 的人定是天赋异禀,林纳斯是参照一个 Minix 系统写的 Linux 内核。如果你对此感兴趣,可以参考这个 1991 年的源代码。
|
||||
|
||||
写一个操作系统本身并不是非常困难。需要了解一些基础的数据结构与算法,硬件设备工作原理。关键是要有参照,比如核心部分可以参考前人的内核。
|
||||
|
||||
但是随着硬件、软件技术发展了这么多年,如果想再写一个大家能够接受的内核,是一件非常困难的事情。内核的能力在上升,硬件的种类在上升,所以 Android 和很多后来的操作系统都是拿 Linux 改装。
|
||||
|
||||
总结
|
||||
|
||||
操作系统中的程序,除去内核部分,剩下绝大多数都可以称为应用。应用是千变万化的,内核是统一而稳定的。操作系统分成 3 层:应用层、内核层、硬件层。因此,内核是连接应用和硬件的桥梁。
|
||||
|
||||
内核需要公平的对待每个 CPU,于是有了用户态和内核态的切换;为了实现切换,需要中断;为了保护内存资源,需要划分用户态和内核态;为了更好地使用计算资源,需要划分线程——而线程需要操作系统内核调度。本模块所讲的内容,还只是对内核理解的冰山一角,后面我们还会从多线程、内存管理、文件系统、虚拟化的角度,重新审视内核的设计。
|
||||
|
||||
最后,我再跟你分享一下我自己的一点小小心得:在给你讲解操作系统的过程中,我仿佛也回到了 20 世纪 70 年代那个风起云涌的时代。在整理操作系统、编程语言、个人电脑领域的大黑客、发明家、企业家们的故事时,我发现这些程序员,强大的不仅仅是技术和创造力,更多的还是对时机的把握。我觉得从这个角度来看,除了要提升自身的技术能力,你也要重视人文知识的学习,这可以帮助你在以后的工作中做得更好。
|
||||
|
||||
好的,操作系统基本概念部分就告一段落。接下来,我们将开始多线程并发相关学习,请和我一起来学习“模块四:进程和线程”吧。
|
||||
|
||||
|
||||
|
||||
|
184
专栏/重学操作系统-完/16WinMacUnixLinux的区别和联系:为什么Debian漏洞排名第一还这么多人用?.md
Normal file
184
专栏/重学操作系统-完/16WinMacUnixLinux的区别和联系:为什么Debian漏洞排名第一还这么多人用?.md
Normal file
@@ -0,0 +1,184 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
16 WinMacUnixLinux 的区别和联系:为什么 Debian 漏洞排名第一还这么多人用?
|
||||
在我的印象中 Windows 才是最容易被攻击的操作系统,没想到 2020 年美国 NIST 的报告中, Debian 竟然是过去 20 年中漏洞最多的操作系统。Debain 以 3067 个漏洞稳居第一,第二名是 Android,第三名是 Linux Kernel。那么为什么 Debian 漏洞数会排在第一位呢?
|
||||
|
||||
|
||||
|
||||
NIST的数据报告:软件漏洞排名
|
||||
|
||||
今天我们就以这个问题为引,带你了解更多的操作系统。这就要追溯到 20 世纪操作系统蓬勃发展的年代。那是一个惊艳绝伦的时代,一个个天才黑客,一场场激烈的商战,一次次震撼的产品发布会——每个人都想改变世界,都在积极的抓住时机,把握时代赋予的机会。我们今天的工程师文化——一种最纯粹的、崇尚知识,崇尚创造的文化,也是传承于此。
|
||||
|
||||
本课时作为内核部分的最后一课,我会带你了解一些操作系统的历史,希望通过这种方式,把这种文化传承下去,让你更有信心去挑战未来的变化。当然,你也可以把本课时当作一个选学的内容,不会影响你继续学习我后面的课程。
|
||||
|
||||
IBM
|
||||
|
||||
话不多说,我们正式开始。1880 年美国进行了一次人口普查,涉及5000 多万人。因为缺少技术手段,总共用了 7 年半时间才完成。后来霍尔列斯发明了一种穿孔制表机,大大改善了这种情况,而后他还给这种机器申请了专利。
|
||||
|
||||
1896 年,霍尔列斯成立了 CRT 公司,也就是 IBM 的前身。后来霍尔列斯经营不善,遇到困难,中间有金融家,军火商都参与过 CRT 的经营,却没能使得情况好转。
|
||||
|
||||
直到 1914 年托马斯·约翰·沃森(老沃森)加盟CRT,帮助霍尔列斯管理 CRT,情况才逐渐好转。老沃森是一个销售出身,很懂得建立销售团队的文化,所以才能逐渐把 CRT 的业务做起来,成为 CRT 的实际掌控者。在 1924 年 CRT 正式更名为 IBM,开启了沃森的时代。
|
||||
|
||||
IBM(International Business Machines Corporation)一开始是卖机器的。后来沃森的儿子,也就是小沃森后来逐渐接管了 IBM。小沃森对蓬勃发展的计算机产业非常感兴趣,同时也很看好计算机市场。但也正因如此,沃森父子间发生了一场冲突。老沃森的著名论断也是出自这场冲突:世界上对计算机有需求的人不会超过 5 个。于是我们都成了这幸运的 5 个人之一。
|
||||
|
||||
所以 IBM 真正开始做计算机是 1949 年小沃森逐渐掌权后。1954 年,IBM 推出了世界上第一个拥有操作系统的商用计算机——IBM 704,并且在 1956 年时独占了计算机市场的 70% 的份额。
|
||||
|
||||
你可能会问,之前的计算机没有操作系统吗?
|
||||
|
||||
我以第一台可编程通用计算机 ENIAC 为例,ENIAC 虽然支持循环、分支判断语句,但是只支持写机器语言。ENIAC 的程序通常需要先写在纸上,然后再由专业的工程师输入到计算机中。 对于 ENIAC 来说执行的是一个个作业,就是每次把输入的程序执行完。
|
||||
|
||||
|
||||
|
||||
上图中的画面正是一位程序员通过操作面板在写程序。 那个时候写程序就是接线和使用操作面板开关,和今天我们所说的“写程序”还是有很大区别的。
|
||||
|
||||
所以在 IBM 704 之前,除了实验室产品外,正式投入使用的计算机都是没有操作系统的。但当时 IBM 704 的操作系统是美国通用移动公司帮助研发的 GM-NAA I/O 系统,而非 IBM 自研。IBM 一直没有重视操作系统的研发能力,这也为后来 IBM 使用微软的操作系统,以及进军个人 电脑 市场的失败埋下了伏笔。
|
||||
|
||||
大型机操作系统
|
||||
|
||||
1975 年前,还没有个人电脑,主要是银行、政府、保险公司这些企业在购买计算机。因为比较强调数据吞吐量,也就是单位时间能够处理的业务数量,因此计算机也被称作大型机。
|
||||
|
||||
早期的大型机厂商往往会为每个大型机写一个操作系统。后来 1964 年 IBM 自研了 OS/360 操作系统,在这个操作系统之上 IBM 推出了 System/360 大型机,然后在 1965~1978 年间,IBM 以 System/360 的代号陆陆续续推出了多款机器。开发 System/360 大型机的过程也被称为 IBM 的一次世纪豪赌,雇用了 6W 员工,新建了 5 个工厂。这么大力度的投资背后是小沃森的支持,几乎是把 IBM 的家底掏空转型去做计算机了。 IBM 这家公司喜欢押注,而且一次比一次大——2019 年 IBM 以 340 亿美金收购红帽,可能是 IBM 想在云计算和操作系统市场发力。
|
||||
|
||||
|
||||
|
||||
IBM 投入了大量人力物力在 System/360 上,也推进了 OS/360 的开发。当时 IBM 还自研了磁盘技术,IBM 自己叫作 DASD(Direct access storage devices)。
|
||||
|
||||
|
||||
|
||||
从上图中你可以看到,IBM 自研的磁盘,非常类似今天硬盘的结构的。当时支持磁盘的操作系统往往叫作 DOS(Disk Operating System)。还有一些是支持磁带的操作系统,叫作 TOS(Tape Operating System)。所以 OS/360 早期叫作 BOS/360,就是 Basic Operating System,后来分成了 DOS/360 和 TOS/360。现在我们不再根据硬件的不同来区分系统了,而是通过驱动程序驱动硬件工作,对硬件的支持更像是插件一样。
|
||||
|
||||
为了支持大型机的工作,IBM 在1957 年还推出了 Fortran(Formula Translation)语言。这是一门非常适合数值计算的语言,目的是更好地支持业务逻辑处理。计算机、语言、操作系统,这应该是早期计算机的三要素。把这三个环节做好,就能占领市场。
|
||||
|
||||
那个时代的操作系统是作业式的,相当于处理一个个任务,核心是一个任务的调度器。它会先一个任务处理,完成后再处理另一个任务,当时 IBM 还没有想过要开发分时操作系统,也就是多个任务轮流调度的模型。直到 Unix 系统的前身 Multics 出现,IBM 为了应对时代变化推出了 TSS/360(T 代表 Time Sharing)。
|
||||
|
||||
和大型机相比,还有一个名词是超级计算机。超级计算机是指拥有其他计算机无法比拟的计算性能的计算机,目前超算每秒可以达到万亿次计算。通常处理业务,不需要超算。超算的作用还是处理科学问题。比如淘宝某次双 11 当天的订单数量是 10 亿量级,单从计算量上说,这并不是很大。如果单纯计算订单状态,恐怕一台手机足矣。但是双 11 期间最恐怖的是 I/O,加上解决大量事务带来的压力,还要同时保证一致性、可用性、分区容错性带来的系统性工作量。
|
||||
|
||||
如果企业没有能力像阿里巴巴一样建立一个分布式集群,同时雇佣大量顶级程序员,就可以直接购买大型机,这样做是相对比较划算的。大型机的主要目标就是为了集中式处理 I/O 和作业提供响应巨大的吞吐量的能力。目前还没有几个企业拥有阿里巴巴处理交易的能力。因此 IBM 的大型机一直拥有非常大的市场。
|
||||
|
||||
比如 IBM 的 z15 大型机,每天可以处理 1 万亿笔订单,内部可以部署 240 万个 Linux 容器。今天的银行交易、航班处理、政府的税务基本都还是大型机在管理。大型机价格也是相对较贵的,一台机器算上硬件、软件和维护费用,一年间花费上亿也是很正常的事情。
|
||||
|
||||
Unix
|
||||
|
||||
IBM 是一家商业驱动的公司,至今已经 100 多年历史。因为 IBM 喜欢用蓝色,大家经常戏称它是 Big Blue(蓝巨人)。IBM 的巨头们有魄力押注,看准了计算机时代的来临,雇用了 60000 员工,开了 5 个工厂,几乎把全部积累的财富都投入到了大型机市场,让 IBM 有了 90% 的大型机市场。商业驱动公司的弱点,就是对驱动技术发展缺少真正的热爱,更多还是商业利益的追逐。
|
||||
|
||||
1964 年贝尔实验室、MIT 和通用电子公司合作开发了 Multics 操作系统,用在了 GE 645 大型机上。GE 开头就是 Generic Electric,通用电气公司,这家公司当时也有想过生产大型机。当时总共有 8 家公司生产大型机,因为做不过 IBM,被戏称为白雪公主和 7 个小矮人。Multics 提出了不少新的概念,比如:
|
||||
|
||||
|
||||
分时(Time Sharing);
|
||||
“08 课时”学习过的环形保护模型;
|
||||
区分不同级别的权限;
|
||||
…
|
||||
|
||||
|
||||
后来 IBM 逐渐对 Multics 引起了重视, 推出 TSS/360 系统,这只是做出防御性部署的一个举措。但是同在贝尔实验室 Multics 项目组的丹尼斯·里奇(C 语言的作者)和肯·汤普逊却看到了希望。他们都是 30 岁不到,正是意气风发的时候。两个人对程序设计、操作系统都有着浓厚的兴趣,特别是肯·汤普逊,之前已经做过大量的操作系统开发,还写过游戏,他们都觉得 Multics 设计太过于复杂了。再加上 Multics 没取得商业成功,贝尔实验室叫停了这个项目后,两个人就开始合作写 Unix。Unix 这个名字一方面参考 Multics,另一方面参考了 Uniplexed,它是 Multiplexed 的反义词,含义有点像统一和简化。
|
||||
|
||||
|
||||
|
||||
Unix 早期开放了源代码,可以说是现代操作系统的奠基之作——支持多任务、多用户,还支持分级安全策略。拥有内核、内存管理、文件系统、正则表达式、开发工具、可执行文件格式、命令行工具等等。可以说,到今天 Unix 不再代表某种操作系统,而是一套统一的,大家都认可的架构标准。
|
||||
|
||||
因为开源的原因,Unix 的版本非常复杂。具体你可以看下面这张大图。
|
||||
|
||||
|
||||
|
||||
绿色的是开源版本,黄色的是混合版本,红色的是闭源版本。这里面有大型机使用的版本,有给工作站使用的版本,也有个人电脑版本。比如 Mac OS、SunOS、Solaris 都有用于个人电脑和工作站;HP-UX 还用作过大型机操作系统。另外,Linux 系统虽然不是 Unix,但是参考了 Unix 的设计,并且遵照 Unix 的规范,它从 Unix 中继承过去不少好用的工具,这种我们称为 Unix-like 操作系统。
|
||||
|
||||
个人电脑革命
|
||||
|
||||
从大型机兴起后,就陆续有人开始做个人电脑。但是第一台真正火了的个人电脑,是 1975 年 MITS 公司推出的 Altair 8800。
|
||||
|
||||
|
||||
|
||||
里面有套餐可选,套餐价是 $439。MITS 的创始人 ED Roberts,和投资人承诺可以卖出去 800 台,没想到第一个月就卖出了 1000 台。对于一台没有显示器、没有键盘,硬件是组装的也不是自有品牌的电脑,它的购买者更多的是个人电脑爱好者们。用户可以通过上面的开关进行编程,然后执行简单的程序,通过观察信号灯看到输出。所以,市场对个人电脑的需求,是普遍存在的,哪怕是好奇心,大家也愿意为之买单。比尔·盖茨也买了这台机器,我们后面再说。
|
||||
|
||||
Altair 8800 出品半年后,做个人电脑的公司就如雨后春笋一样出现了。IBM 当然也嗅到了商机。
|
||||
|
||||
1976 年 21 岁的乔布斯在一次聚会中说服了 26 岁的沃兹尼亚克一起设计 Apple I 电脑。 沃兹尼亚克大二的时候,做过一台组装电脑,在这次聚会上,他的梦想被乔布斯点燃了,当晚就做了 Apple I 的设计图。1976 年 6 月份,Apple I 电脑就生产出了 200 台,最终卖出去 20 多台。 当时 Apple I 只提供一块板,不提供键盘、显示器等设备。这样的电脑竟然有销量,在今天仍然是不可想象的。
|
||||
|
||||
|
||||
|
||||
Apple I 在商业上的发展不太成功,但是 1977 年,乔布斯又说服了投资人,投资生产 Apple II。结果当年就让乔布斯身价上百万,两年后就让他身价过亿。
|
||||
|
||||
|
||||
|
||||
你可以看到 Apple II 就已经是一个完整的机器了。一开始 Apple II 是苹果自研的操作系统,并带有沃兹尼亚克写的简单的 BASIC 语言解释器。1978 年 Apple 公司花了 13000 美金采购了一家小公司的操作系统,这家小公司负责给苹果开发系统,也就是后来的 Apple DOS 操作系统。这家公司还为 Apple DOS 增加了文件浏览器。
|
||||
|
||||
1980s 初, 蓝巨人 IBM 感受到了来自 Apple 的压力。如果个人市场完全被抢占,这对于一家专做商业系统的巨头影响会非常大。因此 IBM 成立了一个特别行动小组,代号 Project Chess,目标就是一年要做出一台能够上市的 PC。但是这次 IBM 没有豪赌,只是组织了一个 150 人的团队。因此,他们决定从硬件到软件都使用其他厂商的,当时的说法叫作开放平台。
|
||||
|
||||
IBM 没有个人电脑上可用的操作系统,因此找到了当时一家做操作系统和个人电脑的厂商,Digital Research 公司。Digital Research 的 CP/M 操作系统已经受到了市场的认可,但是这家公司的创始人竟然拒绝了蓝巨人的提议,态度也不是很友好。这导致 Digital Research 直接错过了登顶的机会。蓝巨人无奈之下,就找到了只有 22 岁的比尔·盖茨。
|
||||
|
||||
盖茨 22 岁的时候和好朋友艾伦创了微软公司。他其实也购买了 Altair 8800(就是本课时前面我们提到的第一台卖火的机器),但是他们目的是和 Altair 的制造商 MITS 公司搞好关系。最终盖茨成功说服了 MITS 公司雇佣艾伦,在 Altair 中提供 BASIC 解释器。BASIC 这门语言 1964 年就存在了,但是盖茨和艾伦是第一个把它迁移到 PC 领域的。IBM 看上了盖茨的团队,加上 Digital Research 拒绝了自己,有点生气,就找到了盖茨。
|
||||
|
||||
盖茨非常重视这次机会。但是这里有个问题,微软当时手上是没有操作系统的,他们连夜搞定了一个方案,就是去购买另一家公司的 86-DOS 操作系统,然后承诺 IBM 自己团队负责修改和维护。微软花了 50000 美金买了 86-DOS 的使用权,允许修改和再发布。然后微软再将 86-DOS 授权给 IBM。这里面有非常多有趣的故事,如果你感兴趣可以去查资料了解更多的内容。
|
||||
|
||||
最后,Project Chess 小组在 1 年内,成功完成了使命,做出了 IBM 个人电脑,看上去非常像 APPLE II。名字就叫 Personal Computer, 就是我们今天说的 PC。86-DOS 也改成了 PC DOS,IBM 的加入又给 PC 市场带了一波节奏,让更多的人了解到了个人电脑。
|
||||
|
||||
|
||||
|
||||
微软也跟着水涨船高,每销售 1 台 PC,微软虽然拿不到利润,但保留了 PC DOS 的版权。而且拿到 IBM 的合同,为 IBM 开发核心系统,这也使得微软的地位大涨。盖茨相信马上就会有其他厂商开始和 IBM 竞争,会需要 PC DOS,而微软只需要专心做好操作系统就足够了。
|
||||
|
||||
其实没有用多久, 1982 年康柏公司花了几个月时间,雇用了 100 多个工程师,逆向工程了 IBM PC,然后就推出了兼容 IBM PC 的电脑,价格稍微便宜一点。然后整个产业沸腾了,各种各样的商家都进来逆向 IBM PC。整个产业陷入了价格战,每过半年人们可以花更少的钱,拿到配置更高的机器。这个时候微软就在背后卖操作系统,也就是 PC DOS 的保真版,MS-DOS。直到 10 年后,微软正式和 IBM 决裂。
|
||||
|
||||
微软第一个视窗操作系统是 1985 年,然后又被 IBM 要求开发它的竞品 OS/2。需要同时推进两个系统,所以微软不是很开心,但是又不能得罪蓝巨人。IBM 也不是很舒服,但是又不得不依赖微软。这个情况一直持续到 1995 年左右,Windows 95 发布的时候,微软还使用 MS-DOS 作为操作系统核心,到了 2001 年 Windows XP 发布的时候,就切换到了 Windows NT 内核。就这样,微软成功发展壮大,并逃离蓝巨人的掌控,成为世界上最大的操作系统公司。
|
||||
|
||||
Linux
|
||||
|
||||
微软的崛起伴随着个人电脑的崛起。但是推动操作系统技术发展,还有另一条线,就是以开源力量为主导的 Unix 线。Unix 出现后,随着一些商业公司逐渐加入,部分公司开始不愿意再公开源代码,而是公开销售修改过的 Unix,这引起了很多黑客的不满。其中比较著名的有理查德·斯托曼和林纳斯。
|
||||
|
||||
大黑客理查德·斯托曼有一次觉得打印机有一部分功能不方便,想要修改,却被施乐公司拒绝提供打印机驱动的源代码,导致了一些茅盾。再加上自己工作的 AI 实验室的成员被商业公司挖走了,他认为商业阻碍了技术进步。于是开始到处呼吁软件应该是自由的、开源的,人们应该拿到源代码进行修改和再发布。
|
||||
|
||||
1985 年理查德·斯托曼发布了 GNU 项目,本身 GNU 是一个左递归,就是 GNU = GNU’s not Unix。GNU 整体来说还是基于 Unix 生态,但在斯托曼的领导下开发了大量的优质工具,比如 gcc 和 emacs 等。但是斯托曼一直为 GNU 没有自己的操作系统而苦恼。
|
||||
|
||||
结果 1991 年 GNU 项目迎来了转机,年仅 21 岁的林纳斯·托瓦兹在网络上发布了一个开源的操作系统,就是 Linux。林纳斯的经历和斯托曼有点类似,所以林纳斯会议听斯托曼讲座,让他有种热血沸腾的感觉。林纳斯不满意 MS-DOS 不开源,但是作为学生党,刚刚学完了 Andy 的《操作系统:设计与实现》,本来一开始没有想过要写 Linux。最后是因为 Unix 的商用版本太贵了买不起,才开始写 Linux。
|
||||
|
||||
斯托曼也觉得 GNU 不能没有操作系统,就统称为 GNU/Linux,并且利用自己的影响力帮助林纳斯推广 Linux。这样就慢慢吸引了世界上一批顶级的黑客,一起来写 Linux。后来 Linux 慢慢成长壮大,成为一块主流的服务器操作系统。当然 Linux 后来也衍生了大量的版本,下图是不同版本的 Linux 的分布。
|
||||
|
||||
|
||||
|
||||
数据取自 W3Techs.com 2020
|
||||
|
||||
Ubuntu 源自 Debian,有着非常漂亮的桌面体验,我就是使用 Ubuntu 开发程序。 Ubuntu 后面有商业公司 Canonical 的支持,也有社区的支持。Centos 源自 Red Hat 公司的企业版 Linux(RHEL),商用版本的各种硬件、软件支持通常会好一些,因此目前国内互联网企业的运维都偏向使用 CentosOS。第三名的 Debian 是 Ubuntu 的源头,是一个完全由自由软件精神驱动的社区产品,提供了大量的自由软件。当然也有人批评 Debian 太过于松散,发行周期太长,漏洞修复周期长等等。
|
||||
|
||||
Android
|
||||
|
||||
乔布斯的苹果电脑最终没有卖过微软的操作系统。但是苹果手机就独占了世界上 2⁄3 的手机利润。苹果手机取得成功后,各大厂商都开始做智能手机。然后 Google 收购了 Android 公司,复刻了微软成功道路。Android 是基于 Linux 改造的。Android 之所以能成功有这么几个原因:
|
||||
|
||||
|
||||
Android 是免费的,因此手机厂商不需要为使用 Android 支付额外的费用,而 Google 可以利用 Google 的移动服务变现,据统计 Google Play 应用商店 + Google 搜索服务 + Google 地图三项一年的营收就可以到 188 亿美金;
|
||||
Android 是开源生态,各大厂商可以基于 Android 修改;
|
||||
Android 系统基于 Linux 稳定性很好,崩溃率很低;
|
||||
最后就是应用生态,用 Android 技术开发 App 可以在各大手机品牌通用。
|
||||
|
||||
|
||||
个人认为还有一个重要原因是比尔·盖茨把微软做大之后,就不再参与微软事物了,醉心于改变人类的事业,所以智能手机、操作系统才有苹果和 Google 的机会。
|
||||
|
||||
总结
|
||||
|
||||
本课时我主要给你介绍了操作系统的历史。
|
||||
|
||||
|
||||
IBM 靠一次豪赌,抓住了大型机的市场,至今仍在盈利。
|
||||
苹果靠个人电脑起家,通过智能手机成为商业巨头。
|
||||
微软靠 IBM 的扶持起家,在个人电脑兴起的浪潮中抓住了机会,成为最大的 PC 操作系统厂商。
|
||||
最后 Google 开源 Android,成为移动端操作系统的王者。
|
||||
|
||||
|
||||
在这几十年的浪潮中,商业竞争风起云涌,但是学术界和黑客们也创造了以自由软件运动为核心的社区文化,操作系统经历了百家争鸣的时代和残酷的淘汰,大浪淘沙,剩下了 Windows 和 Unix 系。Unix 系操作系统包括 Unix、 Linux、Mac OS 和 Android。
|
||||
|
||||
那么通过这节课的学习,你现在可以尝试来回答本节关联的面试题目:为什么 Debian 漏洞排名第一还这么多人用?
|
||||
|
||||
【解析】 首先你要明白漏洞是无可避免的。这是因为软件设计是一个不可计算的问题。因为无法计算,发现漏洞往往需要反复使用软件,或者利用工具扫描看到现象,或者阅读源代码才能找到代码问题……
|
||||
|
||||
那么什么软件漏洞多呢?
|
||||
|
||||
假设开发人员的水平差不多,那么开源软件漏洞一定更多。开放源代码后,可以接触到的源码群体庞大,作为技术资料分析的场景也更庞大,大量开发者讨论和分析设计,技术交流频繁,漏洞往往发现更快。这样你就可以理解为什么 Debian/Android 和 Linux Kernel 位居漏洞排名前三了。
|
||||
|
||||
在 Linux 发行版中,Ubuntu 和 Debian 共享着大量代码,Ubuntu+Debian 市场份额占到 60%,开发群体遍布世界各地,因此 Debian 会被发现其中本来存在着大量的漏洞。Android 同样是开源软件中的佼佼者,开发者依然是一个庞大的群体,因此 Android 漏洞也很多。
|
||||
|
||||
Linux Kernel 代码量级相对 Debian、Android 小,但是有更多的人在用 Linux Kernel 源码,因此漏洞多。而 Windows,因为是闭源产品,所以漏洞反而不容易被发现。
|
||||
|
||||
|
||||
|
||||
|
217
专栏/重学操作系统-完/17进程和线程:进程的开销比线程大在了哪里?.md
Normal file
217
专栏/重学操作系统-完/17进程和线程:进程的开销比线程大在了哪里?.md
Normal file
@@ -0,0 +1,217 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
17 进程和线程:进程的开销比线程大在了哪里?
|
||||
不知你在面试中是否遇到过这样的问题,题目很短,看似简单,但在回答时又感觉有点吃力?比如下面这两个问题:
|
||||
|
||||
|
||||
进程内部都有哪些数据?
|
||||
为什么创建进程的成本很高?
|
||||
|
||||
|
||||
这样的问题确实不好回答,除非你真正理解了进程和线程的原理,否则很容易掉入面试大坑。本讲,我将带你一起探究问题背后的原理,围绕面试题展开理论与实践知识的学习。通过本讲的学习,希望你可以真正理解进程和线程原理,从容应对面试。
|
||||
|
||||
进程和线程
|
||||
|
||||
进程(Process),顾名思义就是正在执行的应用程序,是软件的执行副本。而线程是轻量级的进程。
|
||||
|
||||
进程是分配资源的基础单位。而线程很长一段时间被称作轻量级进程(Light Weighted Process),是程序执行的基本单位。
|
||||
|
||||
在计算机刚刚诞生的年代,程序员拿着一个写好程序的闪存卡,插到机器里,然后电能推动芯片计算,芯片每次从闪存卡中读出一条指令,执行后接着读取下一条指令。闪存中的所有指令执行结束后,计算机就关机。
|
||||
|
||||
|
||||
|
||||
早期的 ENIAC
|
||||
|
||||
一开始,这种单任务的模型,在那个时代叫作作业(Job),当时计算机的设计就是希望可以多处理作业。图形界面出现后,人们开始利用计算机进行办公、购物、聊天、打游戏等,因此一台机器正在执行的程序会被随时切来切去。于是人们想到,设计进程和线程来解决这个问题。
|
||||
|
||||
每一种应用,比如游戏,执行后是一个进程。但是游戏内部需要图形渲染、需要网络、需要响应用户操作,这些行为不可以互相阻塞,必须同时进行,这样就设计成线程。
|
||||
|
||||
资源分配问题
|
||||
|
||||
设计进程和线程,操作系统需要思考分配资源。最重要的 3 种资源是:计算资源(CPU)、内存资源和文件资源。早期的 OS 设计中没有线程,3 种资源都分配给进程,多个进程通过分时技术交替执行,进程之间通过管道技术等进行通信。
|
||||
|
||||
但是这样做的话,设计者们发现用户(程序员),一个应用往往需要开多个进程,因为应用总是有很多必须要并行做的事情。并行并不是说绝对的同时,而是说需要让这些事情看上去是同时进行的——比如图形渲染和响应用户输入。于是设计者们想到了,进程下面,需要一种程序的执行单位,仅仅被分配 CPU 资源,这就是线程。
|
||||
|
||||
轻量级进程
|
||||
|
||||
线程设计出来后,因为只被分配了计算资源(CPU),因此被称为轻量级进程。被分配的方式,就是由操作系统调度线程。操作系统创建一个进程后,进程的入口程序被分配到了一个主线程执行,这样看上去操作系统是在调度进程,其实是调度进程中的线程。
|
||||
|
||||
这种被操作系统直接调度的线程,我们也成为内核级线程。另外,有的程序语言或者应用,用户(程序员)自己还实现了线程。相当于操作系统调度主线程,主线程的程序用算法实现子线程,这种情况我们称为用户级线程。Linux 的 PThread API 就是用户级线程,KThread API 则是内核级线程。
|
||||
|
||||
分时和调度
|
||||
|
||||
因为通常机器中 CPU 核心数量少(从几个到几十个)、进程&线程数量很多(从几十到几百甚至更多),你可以类比为发动机少,而机器多,因此进程们在操作系统中只能排着队一个个执行。每个进程在执行时都会获得操作系统分配的一个时间片段,如果超出这个时间,就会轮到下一个进程(线程)执行。再强调一下,现代操作系统都是直接调度线程,不会调度进程。
|
||||
|
||||
分配时间片段
|
||||
|
||||
如下图所示,进程 1 需要 2 个时间片段,进程 2 只有 1 个时间片段,进程 3 需要 3 个时间片段。因此当进程 1 执行到一半时,会先挂起,然后进程 2 开始执行;进程 2 一次可以执行完,然后进程 3 开始执行,不过进程 3 一次执行不完,在执行了 1 个时间片段后,进程 1 开始执行;就这样如此周而复始。这个就是分时技术。
|
||||
|
||||
|
||||
|
||||
下面这张图更加直观一些,进程 P1 先执行一个时间片段,然后进程 P2 开始执行一个时间片段, 然后进程 P3,然后进程 P4……
|
||||
|
||||
|
||||
|
||||
注意,上面的两张图是以进程为单位演示,如果换成线程,操作系统依旧是这么处理。
|
||||
|
||||
进程和线程的状态
|
||||
|
||||
一个进程(线程)运行的过程,会经历以下 3 个状态:
|
||||
|
||||
|
||||
进程(线程)创建后,就开始排队,此时它会处在“就绪”(Ready)状态;
|
||||
当轮到该进程(线程)执行时,会变成“运行”(Running)状态;
|
||||
当一个进程(线程)将操作系统分配的时间片段用完后,会回到“就绪”(Ready)状态。
|
||||
|
||||
|
||||
我这里一直用进程(线程)是因为旧的操作系统调度进程,没有线程;现代操作系统调度线程。
|
||||
|
||||
|
||||
|
||||
有时候一个进程(线程)会等待磁盘读取数据,或者等待打印机响应,此时进程自己会进入“阻塞”(Block)状态。
|
||||
|
||||
|
||||
|
||||
因为这时计算机的响应不能马上给出来,而是需要等待磁盘、打印机处理完成后,通过中断通知 CPU,然后 CPU 再执行一小段中断控制程序,将控制权转给操作系统,操作系统再将原来阻塞的进程(线程)置为“就绪”(Ready)状态重新排队。
|
||||
|
||||
而且,一旦一个进程(线程)进入阻塞状态,这个进程(线程)此时就没有事情做了,但又不能让它重新排队(因为需要等待中断),所以进程(线程)中需要增加一个“阻塞”(Block)状态。
|
||||
|
||||
|
||||
|
||||
注意,因为一个处于“就绪”(Ready)的进程(线程)还在排队,所以进程(线程)内的程序无法执行,也就是不会触发读取磁盘数据的操作,这时,“就绪”(Ready)状态无法变成阻塞的状态,因此下图中没有从就绪到阻塞的箭头。
|
||||
|
||||
而处于“阻塞”(Block)状态的进程(线程)如果收到磁盘读取完的数据,它又需要重新排队,所以它也不能直接回到“运行”(Running)状态,因此下图中没有从阻塞态到运行态的箭头。
|
||||
|
||||
|
||||
|
||||
进程和线程的设计
|
||||
|
||||
接下来我们思考几个核心的设计约束:
|
||||
|
||||
|
||||
进程和线程在内存中如何表示?需要哪些字段?
|
||||
进程代表的是一个个应用,需要彼此隔离,这个隔离方案如何设计?
|
||||
操作系统调度线程,线程间不断切换,这种情况如何实现?
|
||||
需要支持多 CPU 核心的环境,针对这种情况如何设计?
|
||||
|
||||
|
||||
接下来我们来讨论下这4个问题。
|
||||
|
||||
进程和线程的表示
|
||||
|
||||
可以这样设计,在内存中设计两张表,一张是进程表、一张是线程表。
|
||||
|
||||
进程表记录进程在内存中的存放位置、PID 是多少、当前是什么状态、内存分配了多大、属于哪个用户等,这就有了进程表。如果没有这张表,进程就会丢失,操作系统不知道自己有哪些进程。这张表可以考虑直接放到内核中。
|
||||
|
||||
|
||||
|
||||
细分的话,进程表需要这几类信息。
|
||||
|
||||
|
||||
描述信息:这部分是描述进程的唯一识别号,也就是 PID,包括进程的名称、所属的用户等。
|
||||
资源信息:这部分用于记录进程拥有的资源,比如进程和虚拟内存如何映射、拥有哪些文件、在使用哪些 I/O 设备等,当然 I/O 设备也是文件。
|
||||
内存布局:操作系统也约定了进程如何使用内存。如下图所示,描述了一个进程大致内存分成几个区域,以及每个区域用来做什么。 每个区域我们叫作一个段。
|
||||
|
||||
|
||||
|
||||
|
||||
操作系统还需要一张表来管理线程,这就是线程表。线程也需要 ID, 可以叫作 ThreadID。然后线程需要记录自己的执行状态(阻塞、运行、就绪)、优先级、程序计数器以及所有寄存器的值等等。线程需要记录程序计数器和寄存器的值,是因为多个线程需要共用一个 CPU,线程经常会来回切换,因此需要在内存中保存寄存器和 PC 指针的值。
|
||||
|
||||
用户级线程和内核级线程存在映射关系,因此可以考虑在内核中维护一张内核级线程的表,包括上面说的字段。
|
||||
|
||||
如果考虑到这种映射关系,比如 n-m 的多对多映射,可以将线程信息还是存在进程中,每次执行的时候才使用内核级线程。相当于内核中有个线程池,等待用户空间去使用。每次用户级线程把程序计数器等传递过去,执行结束后,内核线程不销毁,等待下一个任务。这里其实有很多灵活的实现,总体来说,创建进程开销大、成本高;创建线程开销小,成本低。
|
||||
|
||||
隔离方案
|
||||
|
||||
操作系统中运行了大量进程,为了不让它们互相干扰,可以考虑为它们分配彼此完全隔离的内存区域,即便进程内部程序读取了相同地址,而实际的物理地址也不会相同。这就好比 A 小区的 10 号楼 808 和 B 小区的 10 号楼 808 不是一套房子,这种方法叫作地址空间,我们将在“21 讲”的页表部分讨论“地址空间”的详细内容。
|
||||
|
||||
所以在正常情况下进程 A 无法访问进程 B 的内存,除非进程 A 找到了某个操作系统的漏洞,恶意操作了进程 B 的内存,或者利用我们在“21 讲”讲到的“进程间通信”的手段。
|
||||
|
||||
|
||||
|
||||
对于一个进程的多个线程来说,可以考虑共享进程分配到的内存资源,这样线程就只需要被分配执行资源。
|
||||
|
||||
进程(线程)切换
|
||||
|
||||
进程(线程)在操作系统中是不断切换的,现代操作系统中只有线程的切换。 每次切换需要先保存当前寄存器的值的内存,注意 PC 指针也是一种寄存器。当恢复执行的时候,就需要从内存中读出所有的寄存器,恢复之前的状态,然后执行。
|
||||
|
||||
|
||||
|
||||
上面讲到的内容,我们可以概括为以下 5 个步骤:
|
||||
|
||||
|
||||
当操作系统发现一个进程(线程)需要被切换的时候,直接控制 PC 指针跳转是非常危险的事情,所以操作系统需要发送一个“中断”信号给 CPU,停下正在执行的进程(线程)。
|
||||
当 CPU 收到中断信号后,正在执行的进程(线程)会立即停止。注意,因为进程(线程)马上被停止,它还来不及保存自己的状态,所以后续操作系统必须完成这件事情。
|
||||
操作系统接管中断后,趁寄存器数据还没有被破坏,必须马上执行一小段非常底层的程序(通常是汇编编写),帮助寄存器保存之前进程(线程)的状态。
|
||||
操作系统保存好进程状态后,执行调度程序,决定下一个要被执行的进程(线程)。
|
||||
最后,操作系统执行下一个进程(线程)。
|
||||
|
||||
|
||||
|
||||
|
||||
当然,一个进程(线程)被选择执行后,它会继续完成之前被中断时的任务,这需要操作系统来执行一小段底层的程序帮助进程(线程)恢复状态。
|
||||
|
||||
|
||||
|
||||
一种可能的算法就是通过栈这种数据结构。进程(线程)中断后,操作系统负责压栈关键数据(比如寄存器)。恢复执行时,操作系统负责出栈和恢复寄存器的值。
|
||||
|
||||
多核处理
|
||||
|
||||
在多核系统中我们上面所讲的设计原则依然成立,只不过动力变多了,可以并行执行的进程(线程)。通常情况下,CPU 有几个核,就可以并行执行几个进程(线程)。这里强调一个概念,我们通常说的并发,英文是 concurrent,指的在一段时间内几个任务看上去在同时执行(不要求多核);而并行,英文是 parallel,任务必须绝对的同时执行(要求多核)。
|
||||
|
||||
|
||||
|
||||
比如一个 4 核的 CPU 就好像拥有 4 条流水线,可以并行执行 4 个任务。一个进程的多个线程执行过程则会产生竞争条件,这块我们会在“19 讲”锁和信号量部分给你介绍。因为操作系统提供了保存、恢复进程状态的能力,使得进程(线程)也可以在多个核心之间切换。
|
||||
|
||||
创建进程(线程)的 API
|
||||
|
||||
用户想要创建一个进程,最直接的方法就是从命令行执行一个程序,或者双击打开一个应用。但对于程序员而言,显然需要更好的设计。
|
||||
|
||||
站在设计者的角度,你可以这样思考:首先,应该有 API 打开应用,比如可以通过函数打开某个应用;另一方面,如果程序员希望执行完一段代价昂贵的初始化过程后,将当前程序的状态复制好几份,变成一个个单独执行的进程,那么操作系统提供了 fork 指令。
|
||||
|
||||
|
||||
|
||||
也就是说,每次 fork 会多创造一个克隆的进程,这个克隆的进程,所有状态都和原来的进程一样,但是会有自己的地址空间。如果要创造 2 个克隆进程,就要 fork 两次。
|
||||
|
||||
你可能会问:那如果我就是想启动一个新的程序呢?
|
||||
|
||||
我在上文说过:操作系统提供了启动新程序的 API。
|
||||
|
||||
你可能还会问:如果我就是想用一个新进程执行一小段程序,比如说每次服务端收到客户端的请求时,我都想用一个进程去处理这个请求。
|
||||
|
||||
如果是这种情况,我建议你不要单独启动进程,而是使用线程。因为进程的创建成本实在太高了,因此不建议用来做这样的事情:要创建条目、要分配内存,特别是还要在内存中形成一个个段,分成不同的区域。所以通常,我们更倾向于多创建线程。
|
||||
|
||||
不同程序语言会自己提供创建线程的 API,比如 Java 有 Thread 类;go 有 go-routine(注意不是协程,是线程)。
|
||||
|
||||
总结
|
||||
|
||||
本讲我们学习了进程和线程的基本概念。了解了操作系统如何调度进程(线程)和分时算法的基本概念,然后了解进程(线程)的 3 种基本状态。线程也被称作轻量级进程,由操作系统直接调度的,是内核级线程。我们还学习了线程切换保存、恢复状态的过程。
|
||||
|
||||
我们发现进程和线程是操作系统为了分配资源设计的两个概念,进程承接存储资源,线程承接计算资源。而进程包含线程,这样就可以做到进程间内存隔离。这是一个非常巧妙的设计,概念清晰,思路明确,你以后做架构的时候可以多参考这样的设计。 如果只有进程,或者只有线程,都不能如此简单的解决我们遇到的问题。
|
||||
|
||||
那么通过这节课的学习,你现在可以来回答本节关联的面试题目:进程的开销比线程大在了哪里?
|
||||
|
||||
【解析】 Linux 中创建一个进程自然会创建一个线程,也就是主线程。创建进程需要为进程划分出一块完整的内存空间,有大量的初始化操作,比如要把内存分段(堆栈、正文区等)。创建线程则简单得多,只需要确定 PC 指针和寄存器的值,并且给线程分配一个栈用于执行程序,同一个进程的多个线程间可以复用堆栈。因此,创建进程比创建线程慢,而且进程的内存开销更大。
|
||||
|
||||
思考题
|
||||
|
||||
最后我再给你出一道思考题。考虑下面的程序:
|
||||
|
||||
fork()
|
||||
|
||||
fork()
|
||||
|
||||
fork()
|
||||
|
||||
print(“Hello World\n”)
|
||||
|
||||
请问这个程序执行后, 输出结果 Hello World 会被打印几次?
|
||||
|
||||
你可以把你的答案、思路或者课后总结写在留言区,这样可以帮助你产生更多的思考,这也是构建知识体系的一部分。经过长期的积累,相信你会得到意想不到的收获。如果你觉得今天的内容对你有所启发,欢迎分享给身边的朋友。期待看到你的思考!
|
||||
|
||||
|
||||
|
||||
|
425
专栏/重学操作系统-完/18锁、信号量和分布式锁:如何控制同一时间只有2个线程运行?.md
Normal file
425
专栏/重学操作系统-完/18锁、信号量和分布式锁:如何控制同一时间只有2个线程运行?.md
Normal file
@@ -0,0 +1,425 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
18 锁、信号量和分布式锁:如何控制同一时间只有 2 个线程运行?
|
||||
锁是一个面试的热门话题,有乐观锁、悲观锁、重入锁、公平锁、分布式锁。有很多和锁相关的数据结构,比如说阻塞队列。还有一些关联的一些工具,比如说 Semaphore、Monitor 等。这些知识点可以关联很多的面试题目,比如:
|
||||
|
||||
|
||||
锁是如何实现的?
|
||||
如何控制同一时间只有 2 个线程运行?
|
||||
如何实现分布式锁?
|
||||
|
||||
|
||||
面试官通过这类题目考查你的这部分知识,就知道你对并发的理解是停留在表面,还是可以深入原理,去设计高并发的数据结构。这一讲我将帮你把锁类问题一网打尽。
|
||||
|
||||
原子操作
|
||||
|
||||
要想弄清楚锁,就要弄清楚锁的实现,实现锁需要底层提供的原子操作,因此我们先来学习下原子操作。
|
||||
|
||||
原子操作就是操作不可分。在多线程环境,一个原子操作的执行过程无法被中断。那么你可以思考下,具体原子操作的一个示例。
|
||||
|
||||
比如i++就不是一个原子操作,因为它是 3 个原子操作组合而成的:
|
||||
|
||||
|
||||
读取 i 的值;
|
||||
计算 i+1;
|
||||
写入新的值。
|
||||
|
||||
|
||||
像这样的操作,在多线程 + 多核环境会造成竞争条件。
|
||||
|
||||
竞争条件
|
||||
|
||||
竞争条件就是说多个线程对一个资源(内存地址)的读写存在竞争,在这种条件下,最后这个资源的值不可预测,而是取决于竞争时具体的执行顺序。
|
||||
|
||||
举个例子,比如两个线程并发执行i++。那么可以有下面这个操作顺序,假设执行前i=0:
|
||||
|
||||
|
||||
|
||||
虽然上面的程序执行了两次i++,但最终i的值为 1。
|
||||
|
||||
i++这段程序访问了共享资源,也就是变量i,这种访问共享资源的程序片段我们称为临界区。在临界区,程序片段会访问共享资源,造成竞争条件,也就是共享资源的值最终取决于程序执行的时序,因此这个值不是确定的。
|
||||
|
||||
竞争条件是一件非常糟糕的事情,你可以把上面的程序想象成两个自动提款机。如果用户同时操作两个自动提款机,用户的余额就可能会被算错。
|
||||
|
||||
解决竞争条件
|
||||
|
||||
解决竞争条件有很多方案,一种方案就是不要让程序同时进入临界区,这个方案叫作互斥。还有一些方案旨在避免竞争条件,比如 ThreadLocal、 cas 指令以及 “19 讲”中我们要学习的乐观锁。
|
||||
|
||||
避免临界区
|
||||
|
||||
不让程序同时进入临界区这个方案比较简单,核心就是我们给每个线程一个变量i,比如利用 ThreadLocal,这样线程之间就不存在竞争关系了。这样做优点很明显,缺点就是并不是所有的情况都允许你这样做。有一些资源是需要共享的,比如一个聊天室,如果每次用户请求都有一个单独的线程在处理,不可能为每个请求(线程)都维护一份聊天记录。
|
||||
|
||||
cas 指令
|
||||
|
||||
另一个方案是利用 CPU 的指令,让i++成为一个原子操作。 很多 CPU 都提供 Compare And Swap 指令。这个指令的作用是更新一个内存地址的值,比如把i更新为i+1,但是这个指令明确要求使用者必须确定知道内存地址中的值是多少。比如一个线程想把i从100更新到101,线程必须明确地知道现在i是 100,否则就会更新失败。
|
||||
|
||||
cas 可以用下面这个函数表示:
|
||||
|
||||
cas(&oldValue, expectedValue, targetValue)
|
||||
|
||||
|
||||
这里我用的是伪代码,用&符号代表这里取内存地址。注意 cas 是 CPU 提供的原子操作。因此上面的比较和设置值的过程,是原子的,也就是不可分。
|
||||
|
||||
比如想用 cas 更新i的值,而且知道i是 100,想更新成101。那么就可以这样做:
|
||||
|
||||
cas(&i, 100, 101)
|
||||
|
||||
|
||||
如果在这个过程中,有其他线程把i更新为101,这次调用会返回 false,否则返回 true。
|
||||
|
||||
所以i++程序可以等价的修改为:
|
||||
|
||||
// i++等价程序
|
||||
|
||||
cas(&i, i, i+1)
|
||||
|
||||
|
||||
上面的程序执行时,其实是 3 条指令:
|
||||
|
||||
读取i
|
||||
|
||||
计算i+1
|
||||
|
||||
cas操作:比较期望值i和i的真实值的值是否相等,如果是,更新目标值
|
||||
|
||||
|
||||
假设i=0,考虑两个线程分别执行一次这个程序,尝试构造竞争条件:
|
||||
|
||||
|
||||
|
||||
你可以看到通过这种方式,cas 解决了一部分问题,找到了竞争条件,并返回了 false。但是还是无法计算出正确的结果。因为最后一次 cas 失败了。
|
||||
|
||||
如果要完全解决可以考虑这样去实现:
|
||||
|
||||
while(!cas(&i, i, i+1)){
|
||||
|
||||
// 什么都不做
|
||||
|
||||
}
|
||||
|
||||
|
||||
如果 cas 返回 false,那么会尝试再读一次 i 的值,直到 cas 成功。
|
||||
|
||||
tas 指令
|
||||
|
||||
还有一个方案是 tas 指令,有的 CPU 没有提供 cas(大部分服务器是提供的),提供一种 Test-And-Set 指令(tas)。tas 指令的目标是设置一个内存地址的值为 1,它的工作原理和 cas 相似。首先比较内存地址的数据和 1 的值,如果内存地址是 0,那么把这个地址置 1。如果是 1,那么失败。
|
||||
|
||||
所以你可以把 tas 看作一个特殊版的cas,可以这样来理解:
|
||||
|
||||
tas(&lock) {
|
||||
|
||||
return cas(&lock, 0, 1)
|
||||
|
||||
}
|
||||
|
||||
|
||||
锁
|
||||
|
||||
锁(lock),目标是实现抢占(preempt)。就是只让给定数量的线程进入临界区。锁可以用tas或者cas来实现。
|
||||
|
||||
举个例子:如果希望同时只能有一个线程执行i++,伪代码可以这么写:
|
||||
|
||||
enter();
|
||||
|
||||
i++;
|
||||
|
||||
leave();
|
||||
|
||||
|
||||
可以考虑用cas实现enter和leave函数,代码如下:
|
||||
|
||||
int lock = 0;
|
||||
|
||||
enter(){
|
||||
|
||||
while( !cas(&lock, 0, 1) ) {
|
||||
|
||||
// 什么也不做
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
leave(){
|
||||
|
||||
lock = 0;
|
||||
|
||||
}
|
||||
|
||||
|
||||
多个线程竞争一个整数的 lock 变量,0 代表目前没有线程进入临界区,1 代表目前有线程进入临界区。利用cas原子指令我们可以对临界区进行管理。如果一个线程利用 cas 将 lock 设置为 1,那么另一个线程就会一直执行cas操作,直到锁被释放。
|
||||
|
||||
语言级锁的实现
|
||||
|
||||
上面解决竞争条件的时候,我们用到了锁。 相比 cas,锁是一种简单直观的模型。总体来说,cas 更底层,用 cas 解决问题优化空间更大。但是用锁解决问题,代码更容易写——进入临界区之前 lock,出去就 unlock。 从上面这段代码可以看出,为了定义锁,我们需要用到一个整型。如果实现得好,可以考虑这个整数由语言级定义。
|
||||
|
||||
比如考虑让用户传递一个变量过去:
|
||||
|
||||
int lock = 0;
|
||||
|
||||
enter(&lock);
|
||||
|
||||
//临界区代码
|
||||
|
||||
leave(&lock);
|
||||
|
||||
|
||||
自旋锁
|
||||
|
||||
上面我们已经用过自旋锁了,这是之前的代码:
|
||||
|
||||
enter(){
|
||||
|
||||
while( !cas(&lock, 0, 1) ) {
|
||||
|
||||
// 什么也不做
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
这段代码不断在 CPU 中执行指令,直到锁被其他线程释放。这种情况线程不会主动释放资源,我们称为自旋锁。自旋锁的优点就是不会主动发生 Context Switch,也就是线程切换,因为线程切换比较消耗时间。自旋锁缺点也非常明显,比较消耗 CPU 资源。如果自旋锁一直拿不到锁,会一直执行。
|
||||
|
||||
wait 操作
|
||||
|
||||
你可以考虑实现一个 wait 操作,主动触发 Context Switch。这样就解决了 CPU 消耗的问题。但是触发 Context Switch 也是比较消耗成本的事情,那么有没有更好的方法呢?
|
||||
|
||||
enter(){
|
||||
|
||||
while( !cas(&lock, 0, 1) ) {
|
||||
|
||||
// sleep(1000ms);
|
||||
|
||||
wait();
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
你可以看下上面的代码,这里有一个更好的方法:就是 cas 失败后,马上调用sleep方法让线程休眠一段时间。但是这样,可能会出现锁已经好了,但是还需要多休眠一小段时间的情况,影响计算效率。
|
||||
|
||||
另一个方案,就是用wait方法,等待一个信号——直到另一个线程调用notify方法,通知这个线程结束休眠。但是这种情况——wait 和 notify 的模型要如何实现呢?
|
||||
|
||||
生产者消费者模型
|
||||
|
||||
一个合理的实现就是生产者消费者模型。 wait 是一个生产者,将当前线程挂到一个等待队列上,并休眠。notify 是一个消费者,从等待队列中取出一个线程,并重新排队。
|
||||
|
||||
如果使用这个模型,那么我们之前简单用enter和leave来封装加锁和解锁的模式,就需要变化。我们需要把enterleavewaitnotify的逻辑都封装起来,不让用户感知到它们的存在。
|
||||
|
||||
比如 Java 语言,Java 为每个对象增加了一个 Object Header 区域,里面一个锁的位(bit),锁并不需要一个 32 位整数,一个 bit 足够。下面的代码用户使用 synchronized 关键字让临界区访问互斥。
|
||||
|
||||
synchronized(obj){// enter
|
||||
|
||||
// 临界区代码
|
||||
|
||||
} // leave
|
||||
|
||||
|
||||
synchronized 关键字的内部实现,用到了封装好的底层代码——Monitor 对象。每个 Java 对象都关联了一个 Monitor 对象。Monitor 封装了对锁的操作,比如 enter、leave 的调用,这样简化了 Java 程序员的心智负担,你只需要调用 synchronized 关键字。
|
||||
|
||||
另外,Monitor 实现了生产者、消费者模型。
|
||||
|
||||
|
||||
如果一个线程拿到锁,那么这个线程继续执行;
|
||||
如果一个线程竞争锁失败,Montior 就调用 wait 方法触发生产者的逻辑,把线程加入等待集合;
|
||||
如果一个线程执行完成,Monitor 就调用一次 notify 方法恢复一个等待的线程。
|
||||
|
||||
|
||||
这样,Monitor 除了提供了互斥,还提供了线程间的通信,避免了使用自旋锁,还简化了程序设计。
|
||||
|
||||
信号量
|
||||
|
||||
接下来介绍一个叫作信号量的方法,你可以把它看作是互斥的一个广义版。我们考虑一种更加广义的锁,这里请你思考如何同时允许 N 个线程进入临界区呢?
|
||||
|
||||
我们先考虑实现一个基础的版本,用一个整数变量lock来记录进入临界区线程的数量。
|
||||
|
||||
int lock = 0;
|
||||
|
||||
enter(){
|
||||
|
||||
while(lock++ > 2) { }
|
||||
|
||||
}
|
||||
|
||||
leave(){
|
||||
|
||||
lock--;
|
||||
|
||||
}
|
||||
|
||||
|
||||
上面的代码具有一定的欺骗性,没有考虑到竞争条件,执行的时候会出问题,可能会有超过2个线程同时进入临界区。
|
||||
|
||||
下面优化一下,作为一个考虑了竞争条件的版本:
|
||||
|
||||
up(&lock){
|
||||
|
||||
while(!cas(&lock, lock, lock+1)) { }
|
||||
|
||||
}
|
||||
|
||||
down(&lock){
|
||||
|
||||
while(!cas(&lock, lock, lock - 1) || lock == 0){}
|
||||
|
||||
}
|
||||
|
||||
|
||||
为了简化模型,我们重新设计了两个原子操作up和down。up将lock增 1,down将lock减 1。当 lock 为 0 时,如果还在down那么会自旋。考虑用多个线程同时执行下面这段程序:
|
||||
|
||||
int lock = 2;
|
||||
|
||||
down(&lock);
|
||||
|
||||
// 临界区
|
||||
|
||||
up(&lock);
|
||||
|
||||
|
||||
如果只有一个线程在临界区,那么lock等于 1,第 2 个线程还可以进入。 如果两个线程在临界区,第 3 个线程尝试down的时候,会陷入自旋锁。当然我们也可以用其他方式来替代自旋锁,比如让线程休眠。
|
||||
|
||||
当lock初始值为 1 的时候,这个模型就是实现互斥(mutex)。如果 lock 大于 1,那么就是同时允许多个线程进入临界区。这种方法,我们称为信号量(semaphore)。
|
||||
|
||||
信号量实现生产者消费者模型
|
||||
|
||||
信号量可以用来实现生产者消费者模型。下面我们通过一段代码实现生产者消费者:
|
||||
|
||||
int empty = N; // 当前空位置数量
|
||||
|
||||
int mutex = 1; // 锁
|
||||
|
||||
int full = 0; // 当前的等待的线程数
|
||||
|
||||
wait(){
|
||||
|
||||
down(&empty);
|
||||
|
||||
down(&mutex);
|
||||
|
||||
insert();
|
||||
|
||||
up(&mutex);
|
||||
|
||||
up(&full);
|
||||
|
||||
}
|
||||
|
||||
notify(){
|
||||
|
||||
down(&full);
|
||||
|
||||
down(&mutex);
|
||||
|
||||
remove();
|
||||
|
||||
up(&mutex);
|
||||
|
||||
up(&empty)
|
||||
|
||||
}
|
||||
|
||||
insert(){
|
||||
|
||||
wait_queue.add(currentThread);
|
||||
|
||||
yield();
|
||||
|
||||
}
|
||||
|
||||
remove(){
|
||||
|
||||
thread = wait_queue.dequeue();
|
||||
|
||||
thread.resume();
|
||||
|
||||
}
|
||||
|
||||
|
||||
代码中 wait 是生产者,notify 是消费者。 每次wait操作减少一个空位置数量,empty-1;增加一个等待的线程,full+1。每次notify操作增加一个空位置,empty+1,减少一个等待线程,full-1。
|
||||
|
||||
insert和remove方法是互斥的操作,需要用另一个 mutex 锁来保证。insert方法将当前线程加入等待队列,并且调用 yield 方法,交出当前线程的控制权,当前线程休眠。remove方法从等待队列中取出一个线程,并且调用resume进行恢复。以上, 就构成了一个简单的生产者消费者模型。
|
||||
|
||||
死锁问题
|
||||
|
||||
另外就是在并行的时候,如果两个线程互相等待对方获得的锁,就会发生死锁。你可以把死锁理解成一个环状的依赖关系。比如:
|
||||
|
||||
int lock1 = 0;
|
||||
|
||||
int lock2 = 0;
|
||||
|
||||
// 线程1
|
||||
|
||||
enter(&lock1);
|
||||
|
||||
enter(&lock2);
|
||||
|
||||
leave(&lock1);
|
||||
|
||||
leave(&lock2);
|
||||
|
||||
// 线程2
|
||||
|
||||
enter(&lock2);
|
||||
|
||||
enter(&lock1);
|
||||
|
||||
leave(&lock1);
|
||||
|
||||
leave(&lock2)
|
||||
|
||||
|
||||
上面的程序,如果是按照下面这个顺序执行,就会死锁:
|
||||
|
||||
线程1: enter(&lock1);
|
||||
|
||||
线程2: enter(&lock2);
|
||||
|
||||
线程1: enter(&lock2)
|
||||
|
||||
线程2: enter(&lock1)
|
||||
|
||||
// 死锁发生,线程1、2陷入等待
|
||||
|
||||
|
||||
上面程序线程 1 获得了lock1,线程 2 获得了lock2。接下来线程 1 尝试获得lock2,线程 2 尝试获得lock1,于是两个线程都陷入了等待。这个等待永远都不会结束,我们称之为死锁。
|
||||
|
||||
关于死锁如何解决,我们会在“21 | 哲学家就餐问题:什么情况下会触发饥饿和死锁?”讨论。这里我先讲一种最简单的解决方案,你可以尝试让两个线程对锁的操作顺序相同,这样就可以避免死锁问题。
|
||||
|
||||
分布式环境的锁
|
||||
|
||||
最后,我们留一点时间给分布式锁。我们之前讨论了非常多的实现,是基于多个线程访问临界区。现在要考虑一个更庞大的模型,我们有 100 个容器,每一个里面有一个为用户减少积分的服务。
|
||||
|
||||
简化下模型,假设积分存在 Redis 中。当然数据库中也有,但是我们只考虑 Redis。使用 Redis,我们目标是给数据库减负。
|
||||
|
||||
假设这个接口可以看作 3 个原子操作:
|
||||
|
||||
|
||||
从 Redis 读出当前库存;
|
||||
计算库存 -1;
|
||||
更新 Redis 库存。
|
||||
|
||||
|
||||
和i++类似,很明显,当用户并发的访问这个接口,是会发生竞争条件的。 因为程序已经不是在同一台机器上执行了,解决方案就是分布式锁。实现锁,我们需要原子操作。
|
||||
|
||||
在单机多线程并发的场景下,原子操作由 CPU 指令提供,比如 cas 和 tas 指令。那么在分布式环境下,原子操作由谁提供呢?
|
||||
|
||||
有很多工具都可以提供分布式的原子操作,比如 Redis 的 setnx 指令,Zookeeper 的节点操作等等。作为操作系统课程,这部分我不再做进一步的讲解。这里是从多线程的处理方式,引出分布式的处理方式,通过两个类比,帮助你提高。如果你感兴趣,可以自己查阅更多的分布式锁的资料。
|
||||
|
||||
总结
|
||||
|
||||
那么通过这节课的学习,你现在可以尝试来回答本讲关联的面试题目:如何控制同一时间只有 2 个线程运行?
|
||||
|
||||
老规矩,请你先在脑海里构思下给面试官的表述,并把你的思考写在留言区,然后再来看我接下来的分析。
|
||||
|
||||
【解析】 同时控制两个线程进入临界区,一种方式可以考虑用信号量(semaphore)。
|
||||
|
||||
另一种方式是考虑生产者、消费者模型。想要进入临界区的线程先在一个等待队列中等待,然后由消费者每次消费两个。这种实现方式,类似于实现一个线程池,所以也可以考虑实现一个 ThreadPool 类,然后再实现一个调度器类,最后实现一个每次选择两个线程执行的调度算法。
|
||||
|
||||
|
||||
|
||||
|
160
专栏/重学操作系统-完/19乐观锁、区块链:除了上锁还有哪些并发控制方法?.md
Normal file
160
专栏/重学操作系统-完/19乐观锁、区块链:除了上锁还有哪些并发控制方法?.md
Normal file
@@ -0,0 +1,160 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
19 乐观锁、区块链:除了上锁还有哪些并发控制方法?
|
||||
这一讲我带来的面试题是:除了上锁还有哪些并发控制方法?
|
||||
|
||||
上面这道面试题是在“有哪些并发控制方法?”这个问题的基础上加了一个限制条件。
|
||||
|
||||
在我面试候选人的过程中,“上锁”是我听到过回答频次最多的答案,也就是说大多数程序员都可以想到这个并发控制方法。因此,是否能回答出上锁以外的方法,是检验程序员能力的一个分水岭,其实锁以外还有大量优秀的方法。
|
||||
|
||||
你掌握的方法越多,那么在解决实际问题的时候,思路就越多。即使你没有做过高并发场景的设计,但是如果脑海中有大量优秀的方法可以使用,那么公司也会考虑培养你,将高并发场景交给你去解决。今天我们就以这道面试题为引,一起探讨下“锁以外的并发控制方法”。
|
||||
|
||||
悲观锁/乐观锁
|
||||
|
||||
说到并发场景,设计系统的目的往往是达到同步(Synchronized)的状态,同步就是大家最终对数据的理解达成了一致。
|
||||
|
||||
同步的一种方式,就是让临界区互斥。 这种方式,每次只有一个线程可以进入临界区。比如多个人修改一篇文章,这意味着必须等一个人编辑完,另一个人才能编辑。但是从实际问题出发,如果多个人编辑的不是文章的同一部分,是可以同时编辑的。因此,让临界区互斥的方法(对临界区上锁),具有强烈的排他性,对修改持保守态度,我们称为悲观锁(Pressimistic Lock)。
|
||||
|
||||
通常意义上,我们说上锁,就是悲观锁,比如说 MySQL 的表锁、行锁、Java 的锁,本质是互斥(mutex)。
|
||||
|
||||
和悲观锁(PressimisticLock)持相反意见的,是乐观锁(Optimistic Lock)。你每天都用的,基于乐观锁的应用就是版本控制工具 Git。Git 允许大家一起编辑,将结果先存在本地,然后都可以向远程仓库提交,如果没有版本冲突,就可以提交上去。这就是一种典型的乐观锁的场景,或者称为基于版本控制的场景。
|
||||
|
||||
Git 的类比
|
||||
|
||||
比如现在代码仓库的版本是 100。Bob 和 Alice 把版本 100 拷贝到本地,Bob 在本地写到了 106 版本,Alice 在本地写到 108 版本。那么如果 Alice 先提交,代码仓库的版本就到了 108。 Bob 再提交的时候,发现版本已经不是 100 了,就需要把最新的代码 fetch 到本地,然后合并冲突,再尝试提交一个更新的版本,比如 110。
|
||||
|
||||
这种方式非常类似cas指令的形式,就是每次更新的发起方,需要明确地知道想从多少版本更新到多少版本。以 Git 为例,可以写出cas的伪代码:
|
||||
|
||||
cas(&version, 100, 108); // 成功
|
||||
|
||||
cas(&version, 100, 106); // 失败,因为version是108
|
||||
|
||||
|
||||
上面代码第二次cas操作时因为版本变了,更新失败,这就是一个乐观锁——Alice 和 Bob 可以同时写,先更新的人被采纳,后更新的人负责解决冲突。
|
||||
|
||||
购物车的类比
|
||||
|
||||
再举个例子,比如说要实现一个购物车。用户可能在移动端、PC 端之间切换,比如他用一会手机累了,然后换成用电脑,当他用电脑累了,再换回手机。
|
||||
|
||||
在移动端和 PC 端,用户都在操作购物车。 比如在移动端上,用户增加了商品 A;然后用户打开 PC 端,增加了商品 B;然后用户又换回了移动端,想增加商品 C。
|
||||
|
||||
这种时候,如果用悲观锁,用户登录移动端后,一种方案就是把 PC 端下线——当然这个方案显然不合理。 合理的方案是给购物车一个版本号,假设是 MySQL 表,那么购物车表中就会多一个版本字段。这样当用户操作购物车的时候,检查一下当前购物车的版本号是不是最新的,如果是最新的,那么就正常操作。如果不是最新的,就提示用户购物车在其他地方已被更新,需要刷新。
|
||||
|
||||
去中心化方案:区块链的类比
|
||||
|
||||
继续类比,我们可以思考一个更加有趣的方案。在传统的架构中,我们之所以害怕并发,是因为中心化。比如说 DNS 系统,如果全球所有的 DNS 查询都执行一个集群,这个吞吐量是非常恐怖的,因此 DNS 系统用了一个分级缓存的策略。
|
||||
|
||||
但是交易数据分布的时候,比如下单、支付、修改库存,如果用分布式处理,就牵扯到分布式锁(分布式事务)。那么,有没有一个去中心化的方案,让业务不需要集中处理呢?比如说双 11 期间你在淘宝上买东西,可不可以直接和商家下单,而不用通过淘宝的中心系统呢?——如果可以,这也就相当于实现了同步,或者说去掉了高并发的同步。
|
||||
|
||||
解决最基本的信用问题
|
||||
|
||||
考虑购买所有的网购产品,下单不再走中心化的平台。比如阿里、拼多多、 京东、抖音……这些平台用户都不走平台的中心系统下单,而是用户直接和商家签订合同。这个技术现在已经实现了,叫作电子合同。
|
||||
|
||||
举例:Alice(A)向苹果店 B 购买了一个 iPhone。那么双方签订电子合同,合同内容 C 是:
|
||||
|
||||
from=A, to=B, price=10000, signature=alice的签名
|
||||
|
||||
from=B, to=A, object=iphone, signature=苹果店的签名
|
||||
|
||||
|
||||
上面两条记录,第 1 条是说 A 同意给 B 转 10000 块钱;第 2 条记录说,B 同意给 A 一个 iPhone。如果 A 收了 iPhone 不给 B 打款,B 可以拿着这个电子合同去法院告 A。因为用 A 的签名,可以确定是 Alice 签署了这份协议。同理,如果苹果店不给 Alice iPhone,Alice 可以去法院告苹果店,因为 Alice 可以用苹果店的签名证明合同是真的。
|
||||
|
||||
解决货币和库存的问题
|
||||
|
||||
有了上面的例子,最基本的信用问题解决了。接下来,你可能会问,Alice 怎么证明自己有足够的钱买 iPhone?苹果店怎么证明有足够的 iPhone?
|
||||
|
||||
比如在某个对公开放的节点中,记录了:
|
||||
|
||||
account=alice, money=10000
|
||||
|
||||
account=bob, iphone=100
|
||||
|
||||
…… 以及很多其他的数据
|
||||
|
||||
|
||||
我们假设这里的钱可能是 Alice 用某种手段放进来的。或者我们再简化这个模型,比如全世界所有人的钱,都在这个系统里,这样我们就不用关心钱从哪里来这个问题了。如果是比特币,钱是需要挖矿的。
|
||||
|
||||
|
||||
|
||||
如图,这个结构也叫作区块链。每个 Block 下面可以存一些数据,每个 Block 知道上一个节点是谁。每个 Block 有上一个节点的摘要签名。也就是说,如果 Block 10 是 Block 11 的上一个节点,那么 Block 11 会知道 Block 10 的存在,且用 Block 11 中 Block 10 的摘要签名,可以证明 Block 10 的数据没有被篡改过。
|
||||
|
||||
区块链构成了一个基于历史版本的事实链,前一个版本是后一个版本的历史。Alice 的钱和苹果店的 iPhone 数量,包括全世界所有人的钱,都在这些 Block 里。
|
||||
|
||||
购买转账的过程
|
||||
|
||||
下面请你思考,Alice 购买了 iPhone,需要提交两条新数据到上面的区块链。
|
||||
|
||||
from=A, to=B, price=10000, signature=alice的签名
|
||||
|
||||
from=B, to=A, object=iphone, signature=苹果店的签名
|
||||
|
||||
|
||||
那么我们可以在末端节点上再增加一个区块,代表这次交易,如下图:
|
||||
|
||||
|
||||
|
||||
比如,Alice 先在本地完成这件事情,本地的区块链就会像上图那样。 假设有一个中心化的服务器,专门接收这些区块数据,Alice 接下来就可以把数据提交到中心化的服务器,苹果店从中心化服务器上看到这条信息,认为交易被 Alice 执行了,就准备发货。
|
||||
|
||||
如果世界上有很多人同时在这个末端节点上写新的 Block。那么可以考虑由一个可信任的中心服务帮助合并新增的区块数据。就好像多个人同时编辑了一篇文章,发生了冲突,那就可以考虑由一个人整合大家需要修改和新增的内容,避免同时操作产生混乱。
|
||||
|
||||
解决欺诈问题
|
||||
|
||||
正常情况下,所有记录都可以直接合并。但是比如Alice在一家店购买了 1 个 iPhone,在另外一家店购买了 2 个 iPhone,这个时候 Alice 的钱就不够付款了。 或者说 Alice 想用 20000 块买 3 个 iPhone,她还想骗一个。
|
||||
|
||||
那么 Alice 最终就需要写这样的记录:
|
||||
|
||||
from=A, to=B, price=10000, signature=alice的签名
|
||||
|
||||
from=B, to=A, object=iphone, signature=一个苹果店的签名
|
||||
|
||||
from=A, to=B1, price=20000, signature=alice的签名
|
||||
|
||||
from=B1, to=A, object=iphonex2, signature=另一个苹果店的签名
|
||||
|
||||
|
||||
无论 Alice 以什么顺序写入这些记录,她的钱都是不够的,因为她只有 20000 的余额。 这样简单地就解决了欺诈问题。
|
||||
|
||||
如果 Alice 想要修改自己的余额,那么 Alice 怎么做呢?
|
||||
|
||||
Alice 需要新增一个末端的节点,比如她在末端节点上将自己的余额修改为 999999。那么 Alice 的余额,就和之前 Block 中记录的冲突了。简单一查,就知道 Alice 在欺诈。如果 Alice 想要修改之前的某个节点的数据,这个节点的摘要签名就会发生变化了, 那么后面所有的节点就失效了。
|
||||
|
||||
比如 Alice 修改了 Block 9 的数据,并把整个区块链拷贝给 Bob。Bob 通过验证签名,就知道 Alice 在骗人。如果 Alice 修改了所有 Block 9 以后的 Block,相当于修改了完整的一个链条,且修改了所有的签名。Bob 只需要核对其中几个版本和其他人,或者和中心服务的签名的区别就知道 Alice 在欺诈。
|
||||
|
||||
刚才有一个设计,就是有一个中心平台供 Bob 下载。如果中心平台修改了数据。那么 Bob 会马上发现存在本地的和自己相关的数据与中心平台不一致。这样 Bob 就会联合其他用户一起抵制中心平台。
|
||||
|
||||
所以结论是,区块链一旦写入就不能修改,这样可以防止很多欺诈行为。
|
||||
|
||||
解决并发问题
|
||||
|
||||
假设全球有几十亿人都在下单。那么每次下单,需要创建新的一个 Block。这种情况,会导致最后面的 Block,开很多分支。
|
||||
|
||||
|
||||
|
||||
这个时候你会发现,这里有同步问题对不对? 最傻的方案就是用锁解决,比如用一个集中式的办法,去接收所有的请求,这样就又回到中心化的设计。
|
||||
|
||||
还有一个高明的办法,就是允许商家开分支。 用户和苹果店订合同,苹果店独立做一个分支,把用户的合同连起来。
|
||||
|
||||
|
||||
|
||||
这样苹果店自己先维护自己的 Block-Chain,等待合适的时机,再去合并到主分支上。 如果有合同合并不进去,比如余额不足,那再作废这个合同(不发货了)。
|
||||
|
||||
这里请你思考这样一种处理方式:如果全世界每天有 1000 亿笔订单要处理,那么可以先拆分成 100 个区域,每个区域是 10W 家店。这样最终每家店的平均并发量在 10000 单。 然后可以考虑每过多长时间,比如 10s,进行一次逐级合并。
|
||||
|
||||
这样,整体每个节点的压力就不是很大了。
|
||||
|
||||
总结
|
||||
|
||||
在这一讲,我们主要学习了一些比锁更加有趣的处理方式, 其实还有很多方式,你可以去思考。并发问题也不仅仅是要解决并发问题,并发还伴随着一致性、可用性、欺诈及吞吐量等。一名优秀的架构师是需要储备多个维度的知识,所以还是我常常跟你强调的,知识在于积累,绝非朝夕之功。
|
||||
|
||||
另外,我想告诉你的是,其实大厂并不是只招收处理过并发场景的工程师。作为一名资深面试官,我愿意给任何人机会,前提是你的方案打动了我。而设计方案的能力,是可以学习的。你要多思考,多查资料,多整理总结,这样久而久之,就有公司愿意让你做架构了。
|
||||
|
||||
那么通过这节课的学习,你现在可以尝试来回答本节关联的面试题目:除了上锁还有哪些并发控制方法?
|
||||
|
||||
【解析】 这个问题比较发散,这一讲我们介绍了基于乐观锁的版本控制,还介绍了区块链技术。另外还有一个名词,并不属于操作系统课程范畴,我也简单给你介绍下。处理并发还可以考虑 Lock-Free 数据结构。比如 Lock-Free 队列,是基于 cas 指令实现的,允许多个线程使用这个队列。再比如 ThreadLocal,让每个线程访问不同的资源,旨在用空间换时间,也是避免锁的一种方案。
|
||||
|
||||
|
||||
|
||||
|
117
专栏/重学操作系统-完/20线程的调度:线程调度都有哪些方法?.md
Normal file
117
专栏/重学操作系统-完/20线程的调度:线程调度都有哪些方法?.md
Normal file
@@ -0,0 +1,117 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
20 线程的调度:线程调度都有哪些方法?
|
||||
这一讲我带来的面试题目是:线程调度都有哪些方法?
|
||||
|
||||
所谓调度,是一个制定计划的过程,放在线程调度背景下,就是操作系统如何决定未来执行哪些线程?
|
||||
|
||||
这类型的题目考察的并不是一个死的概念,面试官会通过你的回答考量你对知识进行加工和理解的能力。这有点类似于设计技术方案,要对知识进行系统化、结构化地思考和分类。就这道题目而言,可以抓两条主线,第一条是形形色色调度场景怎么来的?第二条是每个调度算法是如何工作的?
|
||||
|
||||
先到先服务
|
||||
|
||||
早期的操作系统是一个个处理作业(Job),比如很多保险业务,每处理一个称为一个作业(Job)。处理作业最容易想到的就是先到先服务(First Come First Service,FCFS),也就是先到的作业先被计算,后到的作业,排队进行。
|
||||
|
||||
这里需要用到一个叫作队列的数据结构,具有先入先出(First In First Out,FIFO)性质。先进入队列的作业,先处理,因此从公平性来说,这个算法非常朴素。另外,一个作业完全完成才会进入下一个作业,作业之间不会发生切换,从吞吐量上说,是最优的——因为没有额外开销。
|
||||
|
||||
但是这样对于等待作业的用户来说,是有问题的。比如一笔需要用时 1 天的作业 ,如果等待了 10 分钟,用户是可以接受的;一个用时 10 分钟的作业,用户等待一天就要投诉了。 因此如果用时 1 天的作业先到,用时 10 分钟的任务后到,应该优先处理用时少的,也就是短作业优先(Shortest Job First,SJF)。
|
||||
|
||||
短作业优先
|
||||
|
||||
通常会同时考虑到来顺序和作业预估时间的长短,比如下面的到来顺序和预估时间:
|
||||
|
||||
|
||||
|
||||
这样就会优先考虑第一个到来预估时间为 3 分钟的任务。 我们还可以从另外一个角度来审视短作业优先的优势,就是平均等待时间。
|
||||
|
||||
平均等待时间 = 总等待时间/任务数
|
||||
|
||||
上面例子中,如果按照 3,3,10 的顺序处理,平均等待时间是:(0 + 3 + 6) / 3 = 3 分钟。 如果按照 10,3,3 的顺序来处理,就是( 0+10+13 )/ 3 = 7.66 分钟。
|
||||
|
||||
平均等待时间和用户满意度是成反比的,等待时间越长,用户越不满意,因此在大多数情况下,应该优先处理用时少的,从而降低平均等待时长。
|
||||
|
||||
采用 FCFS 和 SJF 后,还有一些问题没有解决。
|
||||
|
||||
|
||||
紧急任务如何插队?比如老板安排的任务。
|
||||
等待太久的任务如何插队?比如用户等太久可能会投诉。
|
||||
先执行的大任务导致后面来的小任务没有执行如何处理?比如先处理了一个 1 天才能完成的任务,工作半天后才发现预估时间 1 分钟的任务也到来了。
|
||||
|
||||
|
||||
为了解决上面的问题,我们设计了两种方案, 一种是优先级队列(PriorityQueue),另一种是抢占(Preemption)。
|
||||
|
||||
优先级队列(PriorityQueue)
|
||||
|
||||
刚才提到老板安排的任务需要紧急插队,那么下一个作业是不是应该安排给老板?毫无疑问肯定是这样!那么如何控制这种优先级顺序呢?一种方法是用优先级队列。优先级队列可以给队列中每个元素一个优先级,优先级越高的任务就会被先执行。
|
||||
|
||||
优先级队列的一种实现方法就是用到了堆(Heap)这种数据结构,更最简单的实现方法,就是每次扫描一遍整个队列找到优先级最高的任务。也就是说,堆(Heap)可以帮助你在 O(1) 的时间复杂度内查找到最大优先级的元素。
|
||||
|
||||
比如老板的任务,就给一个更高的优先级。 而对于普通任务,可以在等待时间(W) 和预估执行时间(P) 中,找一个数学关系来描述。比如:优先级 = W/P。W 越大,或者 P 越小,就越排在前面。 当然还可以有很多其他的数学方法,利用对数计算,或者某种特别的分段函数。
|
||||
|
||||
这样,关于紧急任务如何插队?等待太久的任务如何插队?这两个问题我们都解决了,接下来我们来看先执行的大任务导致后面来的小任务没有执行的情况如何处理?
|
||||
|
||||
抢占
|
||||
|
||||
为了解决这个问题,我们需要用到抢占(Preemption)。
|
||||
|
||||
抢占就是把执行能力分时,分成时间片段。 让每个任务都执行一个时间片段。如果在时间片段内,任务完成,那么就调度下一个任务。如果任务没有执行完成,则中断任务,让任务重新排队,调度下一个任务。
|
||||
|
||||
拥有了抢占的能力,再结合之前我们提到的优先级队列能力,这就构成了一个基本的线程调度模型。线程相对于操作系统是排队到来的,操作系统为每个到来的线程分配一个优先级,然后把它们放入一个优先级队列中,优先级最高的线程下一个执行。
|
||||
|
||||
|
||||
|
||||
每个线程执行一个时间片段,然后每次执行完一个线程就执行一段调度程序。
|
||||
|
||||
|
||||
|
||||
图中用红色代表调度程序,其他颜色代表被调度线程的时间片段。调度程序可以考虑实现为一个单线程模型,这样不需要考虑竞争条件。
|
||||
|
||||
上面这个模型已经是一个非常优秀的方案了,但是还有一些问题可以进一步处理得更好。
|
||||
|
||||
|
||||
如果一个线程优先级非常高,其实没必要再抢占,因为无论如何调度,下一个时间片段还是给它。那么这种情况如何实现?
|
||||
如果希望实现最短作业优先的抢占,就必须知道每个线程的执行时间,而这个时间是不可预估的,那么这种情况又应该如何处理?
|
||||
|
||||
|
||||
为了解决上面两个问题,我们可以考虑引入多级队列模型。
|
||||
|
||||
多级队列模型
|
||||
|
||||
多级队列,就是多个队列执行调度。 我们先考虑最简单的两级模型,如图:
|
||||
|
||||
|
||||
|
||||
上图中设计了两个优先级不同的队列,从下到上优先级上升,上层队列调度紧急任务,下层队列调度普通任务。只要上层队列有任务,下层队列就会让出执行权限。
|
||||
|
||||
|
||||
低优先级队列可以考虑抢占 + 优先级队列的方式实现,这样每次执行一个时间片段就可以判断一下高优先级的队列中是否有任务。
|
||||
高优先级队列可以考虑用非抢占(每个任务执行完才执行下一个)+ 优先级队列实现,这样紧急任务优先级有个区分。如果遇到十万火急的情况,就可以优先处理这个任务。
|
||||
|
||||
|
||||
上面这个模型虽然解决了任务间的优先级问题,但是还是没有解决短任务先行的问题。可以考虑再增加一些队列,让级别更多。比如下图这个模型:
|
||||
|
||||
|
||||
|
||||
紧急任务仍然走高优队列,非抢占执行。普通任务先放到优先级仅次于高优任务的队列中,并且只分配很小的时间片;如果没有执行完成,说明任务不是很短,就将任务下调一层。下面一层,最低优先级的队列中时间片很大,长任务就有更大的时间片可以用。通过这种方式,短任务会在更高优先级的队列中执行完成,长任务优先级会下调,也就类似实现了最短作业优先的问题。
|
||||
|
||||
实际操作中,可以有 n 层,一层层把大任务筛选出来。 最长的任务,放到最闲的时间去执行。要知道,大部分时间 CPU 不是满负荷的。
|
||||
|
||||
总结
|
||||
|
||||
那么通过这一讲的学习,你现在可以尝试来回答本节关联的面试题目:线程调度都有哪些方法?
|
||||
|
||||
【解析】 回答这个问题你要把握主线,千万不要教科书般的回答:任务调度分成抢占和非抢占的,抢占的可以轮流执行,也可以用优先级队列执行;非抢占可以先到先服务,也可以最短任务优先。
|
||||
|
||||
上面这种回答可以用来过普通的程序员岗位,但是面试官其实更希望听到你的见解,这是初中级开发人员与高级开发人员之间的差异。
|
||||
|
||||
比如你告诉面试官:非抢占的先到先服务的模型是最朴素的,公平性和吞吐量可以保证。但是因为希望减少用户的平均等待时间,操作系统往往需要实现抢占。操作系统实现抢占,仍然希望有优先级,希望有最短任务优先。
|
||||
|
||||
但是这里有个困难,操作系统无法预判每个任务的预估执行时间,就需要使用分级队列。最高优先级的任务可以考虑非抢占的优先级队列。 其他任务放到分级队列模型中执行,从最高优先级时间片段最小向最低优先级时间片段最大逐渐沉淀。这样就同时保证了小任务先行和高优任务最先执行。
|
||||
|
||||
以上的回答,并不是一种简单的概括,还包含了你对问题的理解和认知。在面试时,正确性并不是唯一的考量指标,面试官更看重候选人的思维能力。这也是为什么很多人面试问题都答上来了,仍然没有拿到 offer 的原因。如果面试目标是正确性,为什么不让你开卷考试呢? 上维基百科看不是更正确吗?
|
||||
|
||||
|
||||
|
||||
|
621
专栏/重学操作系统-完/21哲学家就餐问题:什么情况下会触发饥饿和死锁?.md
Normal file
621
专栏/重学操作系统-完/21哲学家就餐问题:什么情况下会触发饥饿和死锁?.md
Normal file
@@ -0,0 +1,621 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
21 哲学家就餐问题:什么情况下会触发饥饿和死锁?
|
||||
这一讲给你带来的面试题目是:什么情况下会触发饥饿和死锁?
|
||||
|
||||
读题可知,这道题目在提问“场景”,从表面来看,解题思路是列举几个例子。但是在回答这类面试题前你一定要想一想面试官在考察什么,往往在题目中看到“什么情况下”时,其实考察的是你总结和概括信息的能力。
|
||||
|
||||
关于上面这道题目,如果你只回答一个场景,而没有输出概括性的总结内容,就很容易被面试官认为对知识理解不到位,因而挂掉面试。另外,提问死锁和饥饿还有一个更深层的意思,就是考察你在实战中对并发控制算法的理解,是否具备设计并发算法来解决死锁问题并且兼顾性能(并发量)的思维和能力。
|
||||
|
||||
要学习这部分知识有一个非常不错的模型,就是哲学家就餐问题。1965 年,计算机科学家 Dijkstra 为了帮助学生更好地学习并发编程设计的一道练习题,后来逐渐成为大家广泛讨论的问题。
|
||||
|
||||
哲学家就餐问题
|
||||
|
||||
问题描述如下:有 5 个哲学家,围着一个圆桌就餐。圆桌上有 5 份意大利面和 5 份叉子。哲学家比较笨,他们必须拿到左手和右手的 2 个叉子才能吃面。哲学不饿的时候就在思考,饿了就去吃面,吃面的必须前提是拿到 2 个叉子,吃完面哲学家就去思考。
|
||||
|
||||
|
||||
|
||||
假设每个哲学家用一个线程实现,求一种并发控制的算法,让哲学家们按部就班地思考和吃面。当然我这里做了一些改动,比如 Dijkstra 那个年代线程还没有普及,最早的题目每个哲学家是一个进程。
|
||||
|
||||
问题的抽象
|
||||
|
||||
接下来请你继续思考,我们对问题进行一些抽象,比如哲学是一个数组,编号 0~4。我这里用 Java 语言给你演示,哲学家是一个类,代码如下:
|
||||
|
||||
static class Philosopher implements Runnable {
|
||||
|
||||
private static Philosopher[] philosophers;
|
||||
|
||||
static {
|
||||
|
||||
philosophers = new Philosopher[5];
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
这里考虑叉子也使用编号 0~4,代码如下:
|
||||
|
||||
private static Integer[] forks;
|
||||
|
||||
private static Philosopher[] philosophers;
|
||||
|
||||
static {
|
||||
|
||||
for(int i = 0; i < 5; i++) {
|
||||
|
||||
philosophers[i] = new Philosopher(i);
|
||||
|
||||
forks[i] = -1;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
forks[i]的值等于 x,相当于编号为i的叉子被编号为 x 的哲学家拿起;如果等于-1,那么叉子目前放在桌子上。
|
||||
|
||||
我们经常需要描述左、右的关系,为了方便计算,可以设计 1 个帮助函数(helper functions),帮助我们根据一个编号,计算它左边的编号。
|
||||
|
||||
private static int LEFT(int i) {
|
||||
|
||||
return i == 0 ? 4 : i-1;
|
||||
|
||||
}
|
||||
|
||||
|
||||
假设和哲学家编号一致的叉子在右边,这样如果要判断编号为id哲学家是否可以吃面,需要这样做:
|
||||
|
||||
if(forks[LEFT(id)] == id && forks[id] == id) {
|
||||
|
||||
// 可以吃面
|
||||
|
||||
}
|
||||
|
||||
|
||||
然后定义一个_take函数拿起编号为i叉子; 再设计一个_put方法放下叉子:
|
||||
|
||||
void _take(int i) throws InterruptedException {
|
||||
|
||||
Thread.sleep(10);
|
||||
|
||||
forks[i] = id;
|
||||
|
||||
}
|
||||
|
||||
void _put(int i){
|
||||
|
||||
if(forks[i] == id)
|
||||
|
||||
forks[i] = -1;
|
||||
|
||||
}
|
||||
|
||||
|
||||
_take函数之所以会等待 10ms,是因为哲学家就餐问题的实际意义,是 I/O 处理的场景,拿起叉子好比读取磁盘,需要有一等的时间开销,这样思考才有意义。
|
||||
|
||||
然后是对think和eat两个方法的抽象。首先我封装了一个枚举类型,描述哲学家的状态,代码如下:
|
||||
|
||||
enum PHIS {
|
||||
|
||||
THINKING,
|
||||
|
||||
HUNGRY,
|
||||
|
||||
EATING
|
||||
|
||||
}
|
||||
|
||||
|
||||
然后实现think方法,think方法不需要并发控制,但是这里用Thread.sleep模拟实际思考需要的开销,代码如下:
|
||||
|
||||
void think() throws InterruptedException {
|
||||
|
||||
System.out.println(String.format("Philosopher %d thinking...", id));
|
||||
|
||||
Thread.sleep((long) Math.floor(Math.random()*1000));
|
||||
|
||||
this.state = PHIS.HUNGRY;
|
||||
|
||||
|
||||
最后是eat方法:
|
||||
|
||||
void eat() throws InterruptedException {
|
||||
|
||||
synchronized (forks) {
|
||||
|
||||
if(forks[LEFT(id)] == id && forks[id] == id) {
|
||||
|
||||
this.state = PHIS.EATING;
|
||||
|
||||
} else {
|
||||
|
||||
return;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Thread.sleep((long) Math.floor(Math.random()*1000));
|
||||
}
|
||||
|
||||
|
||||
eat方法依赖于forks对象的锁,相当于eat方法这里会同步——因为这里有读取临界区操作做。Thread.sleep依然用于描述eat方法的时间开销。sleep方法没有放到synchronized内是因为在并发控制时,应该尽量较少锁的范围,这样可以增加更大的并发量。
|
||||
|
||||
以上,我们对问题进行了一个基本的抽象。接下来请你思考在什么情况会发生死锁?
|
||||
|
||||
死锁(DeadLock)和活锁(LiveLock)
|
||||
|
||||
首先,可以思考一种最简单的解法,每个哲学家用一个while循环表示,代码如下:
|
||||
|
||||
while(true){
|
||||
|
||||
think();
|
||||
|
||||
_take(LEFT(id));
|
||||
|
||||
_take(id);
|
||||
|
||||
eat();
|
||||
|
||||
_put(LEFT(id));
|
||||
|
||||
_put(id);
|
||||
|
||||
}
|
||||
|
||||
void _take(id){
|
||||
|
||||
while(forks[id] != -1) { Thread.yield(); }
|
||||
|
||||
Thread.sleep(10); // 模拟I/O用时
|
||||
|
||||
}
|
||||
|
||||
|
||||
_take可以考虑阻塞,直到哲学家得到叉子。上面程序我们还没有进行并发控制,会发生竞争条件。 顺着这个思路,就可以想到加入并发控制,代码如下:
|
||||
|
||||
while(true){
|
||||
|
||||
think();
|
||||
|
||||
synchronized(fork[LEFT(id)]) {
|
||||
|
||||
_take(LEFT(id));
|
||||
|
||||
synchronized(fork[id]) {
|
||||
|
||||
_take(id);
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
eat();
|
||||
|
||||
synchronized(fork[LEFT(id)]) {
|
||||
|
||||
_put(LEFT(id));
|
||||
|
||||
synchronized(fork[id]) {
|
||||
|
||||
_put(id);
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
上面的并发控制,会发生死锁问题,大家可以思考这样一个时序,如果 5 个哲学家都同时通过synchronized(fork[LEFT(id)]),有可能会出现下面的情况:
|
||||
|
||||
|
||||
第 0 个哲学家获得叉子 4,接下来请求叉子 0;
|
||||
第 1 个哲学家获得叉子 0,接下来请求叉子 1;
|
||||
第 2 个哲学家获得叉子 1,接下来请求叉子 2;
|
||||
第 3 个哲学家获得叉子 2,接下来请求叉子 3;
|
||||
第 4 个哲学家获得叉子 3,接下来请求叉子 4。
|
||||
|
||||
|
||||
为了帮助你理解,这里我画了一幅图。
|
||||
|
||||
|
||||
|
||||
如上图所示,可以看到这是一种循环依赖的关系,在这种情况下所有哲学家都获得了一个叉子,并且在等待下一个叉子。这种等待永远不会结束,因为没有哲学家愿意放弃自己拿起的叉子。
|
||||
|
||||
以上这种情况称为死锁(Deadlock),这是一种饥饿(Starvation)的形式。从概念上说,死锁是线程间互相等待资源,但是没有一个线程可以进行下一步操作。饥饿就是因为某种原因导致线程得不到需要的资源,无法继续工作。死锁是饥饿的一种形式,因为循环等待无法得到资源。哲学家就餐问题,会形成一种环状的死锁(循环依赖), 因此非常具有代表性。
|
||||
|
||||
死锁有 4 个基本条件。
|
||||
|
||||
|
||||
资源存在互斥逻辑:每次只有一个线程可以抢占到资源。这里是哲学家抢占叉子。
|
||||
持有等待:这里哲学家会一直等待拿到叉子。
|
||||
禁止抢占:如果拿不到资源一直会处于等待状态,而不会释放已经拥有的资源。
|
||||
循环等待:这里哲学家们会循环等待彼此的叉子。
|
||||
|
||||
|
||||
刚才提到死锁也是一种饥饿(Starvation)的形式,饥饿比较简单,就是线程长期拿不到需要的资源,无法进行下一步操作。
|
||||
|
||||
要解决死锁的问题,可以考虑哲学家拿起 1 个叉子后,如果迟迟没有等到下一个叉子,就放弃这次操作。比如 Java 的 Lock Interface 中,提供的tryLock方法,就可以实现定时获取:
|
||||
|
||||
var lock = new ReentrantLock();
|
||||
|
||||
lock.tryLock(5, TimeUnit.SECONDS);
|
||||
|
||||
|
||||
Java 提供的这个能力是拿不到锁,就报异常,并可以依据这个能力开发释放已获得资源的能力。
|
||||
|
||||
但是这样,我们会碰到一个叫作活锁(LiveLock)的问题。LiveLock 也是一种饥饿。可能在某个时刻,所有哲学及都拿起了左手的叉子,然后发现右手的叉子拿不到,就放下了左手的叉子——如此周而复始,这就是一种活锁。所有线程都在工作,但是没有线程能够进一步——解决问题。
|
||||
|
||||
在实际工作场景下,LiveLock 可以靠概率解决,因为同时拿起,又同时放下这种情况不会很多。实际工作场景很多系统,确实依赖于这个问题不频发。但是,优秀的设计者不能把系统设计依托在一个有概率风险的操作上,因此我们需要继续往深一层思考。
|
||||
|
||||
解决方案
|
||||
|
||||
其实解决上述问题有很多的方案,最简单、最直观的方法如下:
|
||||
|
||||
while(true){
|
||||
|
||||
synchronized(someLock) {
|
||||
|
||||
think();
|
||||
|
||||
_take(LEFT(id));
|
||||
|
||||
_take(id);
|
||||
|
||||
eat();
|
||||
|
||||
_put(LEFT(id));
|
||||
|
||||
_put(id);
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
上面这段程序同时只允许一个哲学家使用所有资源,我们用synchronized构造了一种排队的逻辑。而哲学家,每次必须拿起所有的叉子,吃完,再到下一哲学家。 这样并发度是 1,同时最多有一个线程在执行。 这样的方式可以完成任务,但是性能太差。
|
||||
|
||||
另一种方法是规定拿起过程必须同时拿起,放下过程也同时放下,代码如下:
|
||||
|
||||
while(true){
|
||||
|
||||
think();
|
||||
|
||||
synchronized(someLock) {
|
||||
|
||||
_takeForks();
|
||||
|
||||
}
|
||||
|
||||
eat();
|
||||
|
||||
synchronized(someLock) {
|
||||
|
||||
_puts();
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
void _takeForks(){
|
||||
|
||||
if( forks[LEFT(id)] == -1 && forks[id] == -1 ) {
|
||||
|
||||
forks[LEFT(id)] = id;
|
||||
|
||||
forks[id] = id;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
void _puts(){
|
||||
|
||||
if(forks[LEFT(id)] == id)
|
||||
|
||||
forks[LEFT(id)] = -1;
|
||||
|
||||
if(forks[id] == id)
|
||||
|
||||
forks[id] = -1;
|
||||
|
||||
}
|
||||
|
||||
|
||||
上面这段程序,think函数没有并发控制,一个哲学家要么拿起两个叉子,要么不拿起,这样并发度最高为 2(最多有两个线程同时执行)。而且,这个算法中只有一个锁,因此不存在死锁和饥饿问题。
|
||||
|
||||
到这里,我们已经对这个问题有了一个初步的方案,那么如何进一步优化呢?
|
||||
|
||||
思考和最终方案
|
||||
|
||||
整个问题复杂度的核心在于哲学家拿起叉子是有成本的。好比线程读取磁盘,需要消耗时间。哲学家的思考,是独立的。好比读取了磁盘数据,进行计算。那么有没有办法允许 5 个哲学家都同时去拿叉子呢?这样并发度是最高的。
|
||||
|
||||
经过初步思考,马上会发现这里有环状依赖, 会出现死锁。 原因就是如果 5 个哲学家同时拿叉子,那就意味着有的哲学家必须要放弃叉子。但是如果不放下会出现什么情况呢?
|
||||
|
||||
假设当一个哲学家发现自己拿不到两个叉子的时候,他去和另一个哲学家沟通把自己的叉子给对方。这样就相当于,有一个转让方法。相比于磁盘 I/O,转让内存中的数据成本就低的多了。 我们假设有这样一个转让的方法,代码如下:
|
||||
|
||||
void _transfer(int fork, int philosopher) {
|
||||
|
||||
forks[fork] = philosopher;
|
||||
|
||||
dirty[fork] = false;
|
||||
|
||||
}
|
||||
|
||||
|
||||
这个方法相当于把叉子转让给另一个哲学家,这里你先不用管上面代码中的 dirty,后文中会讲到。而获取叉子的过程,我们可以进行调整,代码如下:
|
||||
|
||||
void take(int i) throws InterruptedException {
|
||||
|
||||
synchronized (forks[i]) {
|
||||
|
||||
if(forks[i] == -1) {
|
||||
|
||||
_take(id);
|
||||
|
||||
} else {
|
||||
|
||||
Philosopher other = philosophers[forks[i]];
|
||||
|
||||
if(other.state != PHIS.EATING && dirty[i]) {
|
||||
|
||||
other._transfer(i, forks[i]);
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
void _take(int i) throws InterruptedException {
|
||||
|
||||
Thread.sleep(10);
|
||||
|
||||
forks[i] = id;
|
||||
|
||||
}
|
||||
|
||||
|
||||
这里我们把每个叉子看作一个锁,有多少个叉子,就有多少个锁,相当于同时可以拿起 5 个叉子(并发度是 5)。如果当前没有人拿起叉子,那么可以自己拿起。 如果叉子属于其他哲学家,就需要判断对方的状态。只要对方不在EATING,就可以考虑转让叉子。
|
||||
|
||||
最后是对 LiveLock 的思考,为了避免叉子在两个哲学家之间来回转让,我们为每个叉子增加了一个dirty属性。一开始叉子的dirty是true,每次转让后,哲学家会把自己的叉子擦干净给另一个哲学家。转让的前置条件是叉子是dirty的,所以叉子在两个哲学家之间只会转让一次。
|
||||
|
||||
通过上面算法,我们就可以避免死锁、饥饿以及提高读取数据(获取叉子)的并发度。最后完整的程序如下,给你做参考:
|
||||
|
||||
package test;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
import java.util.concurrent.ExecutorService;
|
||||
|
||||
import java.util.concurrent.Executors;
|
||||
|
||||
import java.util.concurrent.ThreadPoolExecutor;
|
||||
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import java.util.concurrent.locks.ReentrantLock;
|
||||
|
||||
import java.util.concurrent.locks.StampedLock;
|
||||
|
||||
public class DiningPhilosophers {
|
||||
|
||||
enum PHIS {
|
||||
|
||||
THINKING,
|
||||
|
||||
HUNGRY,
|
||||
|
||||
EATING
|
||||
|
||||
}
|
||||
|
||||
static class Philosopher implements Runnable {
|
||||
|
||||
private static Philosopher[] philosophers;
|
||||
|
||||
private static Integer[] forks;
|
||||
|
||||
private static boolean[] dirty;
|
||||
|
||||
private PHIS state = PHIS.THINKING;
|
||||
|
||||
static {
|
||||
|
||||
philosophers = new Philosopher[5];
|
||||
|
||||
forks = new Integer[5];
|
||||
|
||||
dirty = new boolean[5];
|
||||
|
||||
for(int i = 0; i < 5; i++) {
|
||||
|
||||
philosophers[i] = new Philosopher(i);
|
||||
|
||||
forks[i] = -1;
|
||||
|
||||
dirty[i] = true;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private static int LEFT(int i) {
|
||||
|
||||
return i == 0 ? 4 : i-1;
|
||||
|
||||
}
|
||||
|
||||
public Philosopher(int id) {
|
||||
|
||||
this.id = id;
|
||||
|
||||
}
|
||||
|
||||
private int id;
|
||||
|
||||
void think() throws InterruptedException {
|
||||
|
||||
System.out.println(String.format("Philosopher %d thinking...", id));
|
||||
|
||||
Thread.sleep((long) Math.floor(Math.random()*1000));
|
||||
|
||||
this.state = PHIS.HUNGRY;
|
||||
|
||||
}
|
||||
|
||||
System.out.println(Arrays.toString(forks));
|
||||
|
||||
//System.out.println(Arrays.toString(dirty));
|
||||
|
||||
if(forks[LEFT(id)] == id && forks[id] == id) {
|
||||
|
||||
this.state = PHIS.EATING;
|
||||
|
||||
} else {
|
||||
|
||||
return;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
System.out.println(String.format("Philosopher %d eating...", id));
|
||||
|
||||
Thread.sleep((long) Math.floor(Math.random()*1000));
|
||||
|
||||
synchronized (forks) {
|
||||
|
||||
dirty[LEFT(id)] = true;
|
||||
|
||||
dirty[id] = true;
|
||||
|
||||
}
|
||||
|
||||
var lock = new ReentrantLock();
|
||||
|
||||
lock.tryLock(5, TimeUnit.SECONDS);
|
||||
|
||||
state = PHIS.THINKING;
|
||||
|
||||
}
|
||||
|
||||
void _take(int i) throws InterruptedException {
|
||||
|
||||
Thread.sleep(10);
|
||||
|
||||
forks[i] = id;
|
||||
|
||||
}
|
||||
|
||||
void _transfer(int fork, int philosopher) {
|
||||
|
||||
forks[fork] = philosopher;
|
||||
|
||||
dirty[fork] = false;
|
||||
|
||||
}
|
||||
|
||||
void _putdown(int i) throws InterruptedException {
|
||||
|
||||
Thread.sleep(10);
|
||||
|
||||
forks[i] = -1;
|
||||
|
||||
}
|
||||
|
||||
void take(int i) throws InterruptedException {
|
||||
|
||||
synchronized (forks[i]) {
|
||||
|
||||
if(forks[i] == -1) {
|
||||
|
||||
_take(id);
|
||||
|
||||
} else {
|
||||
|
||||
Philosopher other = philosophers[forks[i]];
|
||||
|
||||
if(other.state != PHIS.EATING && dirty[i]) {
|
||||
|
||||
other._transfer(i, forks[i]);
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
void takeForks() throws InterruptedException {
|
||||
|
||||
take(LEFT(id));
|
||||
|
||||
take(id);
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
public void run() {
|
||||
|
||||
try {
|
||||
|
||||
while(true) {
|
||||
|
||||
think();
|
||||
|
||||
while (state == PHIS.HUNGRY) {
|
||||
|
||||
takeForks();
|
||||
|
||||
System.out.println("here--" + Math.random());
|
||||
|
||||
eat();
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
} catch (InterruptedException e) {
|
||||
|
||||
e.printStackTrace();
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public static void main(String[] args) {
|
||||
|
||||
for(int i = 0; i < 5; i++) {
|
||||
|
||||
new Thread(new Philosopher(i)).start();
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
总结
|
||||
|
||||
那么通过这节课的学习,你现在可以尝试来回答本节关联的面试题目:什么情况下会触发饥饿和死锁?
|
||||
|
||||
【解析】 线程需要资源没有拿到,无法进行下一步,就是饥饿。死锁(Deadlock)和活锁(Livelock)都是饥饿的一种形式。 非抢占的系统中,互斥的资源获取,形成循环依赖就会产生死锁。死锁发生后,如果利用抢占解决,导致资源频繁被转让,有一定概率触发活锁。死锁、活锁,都可以通过设计并发控制算法解决,比如哲学家就餐问题。
|
||||
|
||||
|
||||
|
||||
|
103
专栏/重学操作系统-完/22进程间通信:进程间通信都有哪些方法?.md
Normal file
103
专栏/重学操作系统-完/22进程间通信:进程间通信都有哪些方法?.md
Normal file
@@ -0,0 +1,103 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
22 进程间通信: 进程间通信都有哪些方法?
|
||||
这节课带给你的面试题目是:进程间通信都有哪些方法?
|
||||
|
||||
在上一讲中,我们提到过,凡是面试官问“什么情况下”的时候,面试官实际想听的是你经过理解,整理得到的认知。回答应该是概括的、简要的。而不是真的去列举每一种 case。
|
||||
|
||||
另外,面试官考察进程间通信,有一个非常重要的意义——进程间通信是架构复杂系统的基石。复杂系统往往是分成各种子系统、子模块、微服务等等,按照 Unix 的设计哲学,系统的每个部分应该是稳定、独立、简单有效,而且强大的。系统本身各个模块就像人的器官,可以协同工作。而这个协同的枢纽,就是我们今天的主题——进程间通信。
|
||||
|
||||
什么是进程间通信?
|
||||
|
||||
进程间通信(Intermediate Process Communication,IPC)。所谓通信就是交换数据。所以,狭义地说,就是操作系统创建的进程们之间在交换数据。 我们今天不仅讨论狭义的通信,还要讨论 IPC 更广泛的意义——程序间的通信。 程序可以是进程,可以是线程,可以是一个进程的两个部分(进程自己发送给自己),也可以是分布式的——总之,今天讨论的是广义的交换数据。
|
||||
|
||||
管道
|
||||
|
||||
之前我们在“07 | 进程、重定向和管道指令:xargs 指令的作用是?”中讲解过管道和命名管道。 管道提供了一种非常重要的能力,就是组织计算。进程不用知道有管道存在,因此管道的设计是非侵入的。程序员可以先着重在程序本身的设计,只需要预留响应管道的接口,就可以利用管道的能力。比如用shell执行MySQL语句,可能会这样:
|
||||
|
||||
进程1 | 进程2 | 进程3 | mysql -u... -p | 爬虫进程
|
||||
|
||||
|
||||
我们可以由进程 1、进程 2、进程 3 计算出 MySQL 需要的语句,然后直接通过管道执行。MySQL经过计算将结果传给一个爬虫进程,爬虫就开始工作。MySQL并不是设计用于管道,爬虫进程也不是设计专门用于管道,只是程序员恰巧发现可以这样用,完美地解决了自己的问题,比如:用管道构建一个微型爬虫然后把结果入库。
|
||||
|
||||
我们还学过一个词叫作命名管道。命名管道并没有改变管道的用法。相比匿名管道,命名管道提供了更多的编程手段。比如:
|
||||
|
||||
进程1 > namedpipe
|
||||
|
||||
进程2 > namedpipe
|
||||
|
||||
|
||||
上面的程序将两个进程的临时结果都同时重定向到 namedpipe,相当于把内容合并了再找机会处理。再比如说,你的进程要不断查询本地的 MySQL,也可以考虑用命名管道将查询传递给 MySQL,再用另一个命名管道传递回来。这样可以省去和 localhost 建立 TCP 3 次握手的时间。 当然,现在数据库都是远程的了,这里只是一个例子。
|
||||
|
||||
管道的核心是不侵入、灵活,不会增加程序设计负担,又能组织复杂的计算过程。
|
||||
|
||||
本地内存共享
|
||||
|
||||
同一个进程的多个线程本身是共享进程内存的。 这种情况不需要特别考虑共享内存。如果是跨进程的线程(或者理解为跨进程的程序),可以考虑使用共享内存。内存共享是现代操作系统提供的能力, Unix 系操作系统,包括 Linux 中有 POSIX 内存共享库——shmem。(如果你感兴趣可以参考网页中的内容,这里不做太深入地分析。)Linux 内存共享库的实现原理是以虚拟文件系统的形式,从内存中划分出一块区域,供两个进程共同使用。看上去是文件,实际操作是内存。
|
||||
|
||||
共享内存的方式,速度很快,但是程序不是很好写,因为这是一种侵入式的开发,也就是说你需要为此撰写大量的程序。比如如果修改共享内存中的值,需要调用 API。如果考虑并发控制,还要处理同步问题等。因此,只要不是高性能场景,进程间通信通常不考虑共享内存的方式。
|
||||
|
||||
本地消息/队列
|
||||
|
||||
内存共享不太好用,因此本地消息有两种常见的方法。一种是用消息队列——现代操作系统都会提供类似的能力。Unix 系可以使用 POSIX 标准的 mqueue。另一种方式,就是直接用网络请求,比如 TCP/IP 协议,也包括建立在这之上的更多的通信协议(这些我们在下文中的“远程调用”部分详细讲解)。
|
||||
|
||||
本质上,这些都是收/发消息的模式。进程将需要传递的数据封装成格式确定的消息,这对写程序非常有帮助。程序员可以根据消息类型,分门别类响应消息;也可以根据消息内容,触发特殊的逻辑操作。在消息体量庞大的情况下,也可以构造生产者队列和消费者队列,用并发技术进行处理。
|
||||
|
||||
远程调用
|
||||
|
||||
远程调用(Remote Procedure Call,RPC)是一种通过本地程序调用来封装远程服务请求的方法。
|
||||
|
||||
程序员调用 RPC 的时候,程序看上去是在调用一个本地的方法,或者执行一个本地的任务,但是后面会有一个服务程序(通常称为 stub),将这种本地调用转换成远程网络请求。 同理,服务端接到请求后,也会有一个服务端程序(stub),将请求转换为一个真实的服务端方法调用。
|
||||
|
||||
|
||||
|
||||
客户端服务端的通信
|
||||
|
||||
你可以观察上面这张图,表示客户端和服务端通信的过程,一共是 10 个步骤,分别是:
|
||||
|
||||
|
||||
客户端调用函数(方法);
|
||||
stub 将函数调用封装为请求;
|
||||
客户端 socket 发送请求,服务端 socket 接收请求;
|
||||
服务端 stub 处理请求,将请求还原为函数调用;
|
||||
执行服务端方法;
|
||||
返回结果传给 stub;
|
||||
stub 将返回结果封装为返回数据;
|
||||
服务端 socket 发送返回数据,客户端 socket 接收返回数据;
|
||||
客户端 socket 将数据传递给客户端 stub;
|
||||
客户端 stub 把返回数据转义成函数返回值。
|
||||
|
||||
|
||||
RPC 调用过程有很多约定, 比如函数参数格式、返回结果格式、异常如何处理。还有很多细粒度的问题,比如处理 TCP 粘包、处理网络异常、I/O 模式选型——其中有很多和网络相关的知识比较复杂,你可以参考我将在拉勾教育上线的《计算机网络》专栏。
|
||||
|
||||
上面这些问题比较棘手,因此在实战中通常的做法是使用框架。比如 Thrift 框架(Facebook 开源)、Dubbo 框架(阿里开源)、grpc(Google 开源)。这些 RPC 框架通常支持多种语言,这需要一个接口定义语言支持在多个语言间定义接口(IDL)。
|
||||
|
||||
RPC 调用的方式比较适合微服务环境的开发,当然 RPC 通常需要专业团队的框架以支持高并发、低延迟的场景。不过,硬要说 RPC 有额外转化数据的开销(主要是序列化),也没错,但这不是 RPC 的主要缺点。RPC 真正的缺陷是增加了系统间的耦合。当系统主动调用另一个系统的方法时,就意味着在增加两个系统的耦合。长期增加 RPC 调用,会让系统的边界逐渐腐化。这才是使用 RPC 时真正需要注意的东西。
|
||||
|
||||
消息队列
|
||||
|
||||
既然 RPC 会增加耦合,那么怎么办呢——可以考虑事件。事件不会增加耦合,如果一个系统订阅了另一个系统的事件,那么将来无论谁提供同类型的事件,自己都可以正常工作。系统依赖的不是另一个系统,而是某种事件。如果哪天另一个系统不存在了,只要事件由其他系统提供,系统仍然可以正常运转。
|
||||
|
||||
实现事件可以用消息队列。具体这块架构技术我不再展开,你如果感兴趣可以课下去研究 Doman Drive Design 这个方向的知识。
|
||||
|
||||
另一个用到消息队列的场景是纯粹大量数据的传输。 比如日志的传输,中间可能还会有收集、清洗、筛选、监控的节点,这就构成了一个庞大的分布式计算网络。
|
||||
|
||||
总的来说,消息队列是一种耦合度更低,更加灵活的模型。但是对系统设计者的要求也会更高,对系统本身的架构也会有一定的要求。具体场景的消息队列有 Kafka,主打处理 feed;RabbitMQ、ActiveMQ、 RocketMQ 等主打分布式应用间通信(应用解耦)。
|
||||
|
||||
总结
|
||||
|
||||
那么通过这节课的学习,你现在可以尝试来回答本节关联的面试题目:进程间通信都有哪些方法?
|
||||
|
||||
【解析】 你可以从单机和分布式角度给面试管阐述。
|
||||
|
||||
|
||||
如果考虑单机模型,有管道、内存共享、消息队列。这三个模型中,内存共享程序最难写,但是性能最高。管道程序最好写,有标准接口。消息队列程序也比较好写,比如用发布/订阅模式实现具体的程序。
|
||||
如果考虑分布式模型,就有远程调用、消息队列和网络请求。直接发送网络请求程序不好写,不如直接用实现好的 RPC 调用框架。RPC 框架会增加系统的耦合,可以考虑 消息队列,以及发布订阅事件的模式,这样可以减少系统间的耦合。
|
||||
|
||||
|
||||
|
||||
|
||||
|
545
专栏/重学操作系统-完/23(1)加餐练习题详解(四).md
Normal file
545
专栏/重学操作系统-完/23(1)加餐练习题详解(四).md
Normal file
@@ -0,0 +1,545 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
23 (1)加餐 练习题详解(四)
|
||||
今天我会带你把《模块四:进程和多线程》中涉及的课后练习题,逐一讲解,并给出每个课时练习题的解题思路和答案。
|
||||
|
||||
练习题详解
|
||||
|
||||
17 | 进程和线程:进程的开销比线程大在了哪里?
|
||||
|
||||
【问题】考虑下面的程序:
|
||||
|
||||
fork()
|
||||
|
||||
fork()
|
||||
|
||||
fork()
|
||||
|
||||
print(“Hello World\n”)
|
||||
|
||||
请问这个程序执行后, 输出结果 Hello World 会被打印几次?
|
||||
|
||||
【解析】 这道题目考察大家对 fork 能力的理解。
|
||||
|
||||
fork 的含义是复制一份当前进程的全部状态。第 1 个 fork 执行 1 次产生 1 个额外的进程。 第 2 个 fork,执行 2 次,产生 2 个额外的进程。第 3 个 fork 执行 4 次,产生 4 个额外的进程。所以执行 print 的进程一共是 8 个。
|
||||
|
||||
18 | 锁、信号量和分布式锁:如何控制同一时间只有 2 个线程运行?
|
||||
|
||||
【问题】如果考虑到 CPU 缓存的存在,会对上面我们讨论的算法有什么影响?
|
||||
|
||||
【解析】 这是一道需要大家查一些资料的题目。这里涉及一个叫作内存一致性模型的概念。具体就是说,在同一时刻,多线程之间,对内存中某个地址的数据认知是否一致(简单理解,就是多个线程读取同一个内存地址能不能读到一致的值)。
|
||||
|
||||
对某个地址,和任意时刻,如果所有线程读取值,得到的结果都一样,是一种强一致性,我们称为线性一致性(Sequencial Consistency),含义就是所有线程对这个地址中数据的历史达成了一致,历史没有分差,有一条大家都能认可的主线,因此称为线性一致。 如果只有部分时刻所有线程的理解是一致的,那么称为弱一致性(Weak Consistency)。
|
||||
|
||||
那么为什么会有内存不一致问题呢? 这就是因为 CPU 缓存的存在。
|
||||
|
||||
|
||||
|
||||
如上图所示:假设一开始 A=0,B=0。两个不在同一个 CPU 核心执行的 Thread1、Thread2 分别执行上图中的简单程序。在 CPU 架构中,Thread1,Thread2 在不同核心,因此它们的 L1\L2 缓存不共用, L3 缓存共享。
|
||||
|
||||
在这种情况下,如果 Thread1 发生了写入 A=1,这个时候会按照 L1,L2,L3 的顺序写入缓存,最后写内存。而对于 Thread2 而言,在 Thread1 刚刚发生写入时,如果去读取 A 的值,就需要去内存中读,这个时候 A=1 可能还没有写入内存。但是对于线程 1 来说,它只要发生了写入 A=1,就可以从 L1 缓存中读取到这次写入。所以在线程 1 写入 A=1 的瞬间,线程 1 线程 2 无法对 A 的值达成一致,造成内存不一致。这个结果会导致 print 出来的 A 和 B 结果不确定,可能是 0 也可能是 1,取决于具体线程执行的时机。
|
||||
|
||||
考虑一个锁变量,和 cas 上锁操作,代码如下:
|
||||
|
||||
int lock = 0
|
||||
|
||||
void lock() {
|
||||
|
||||
while(!cas(&lock, 0, 1)){
|
||||
|
||||
// CPU降低功耗的指令
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
上述程序构成了一个简单的自旋锁(spin-lock)。如果考虑到内存一致性模型,线程 1 通过 cas 操作将 lock 从 0 置 1。这个操作会先发生在线程所在 CPU 的 L1 缓存中。cas 函数通过底层 CPU 指令保证了原子性,cas 执行完成之前,线程 2 的 cas 无法执行。当线程 1 开始临界区的时候,假设这个时候线程 2 开始执行,尝试获取锁。如果这个过程切换非常短暂,线程 2 可能会从内存中读取 lock 的值(而这个值可能还没有写入,还在 Thread 所在 CPU 的 L1、L2 中),线程 2 可能也会通过 cas 拿到锁。两个线程同时进入了临界区,造成竞争条件。
|
||||
|
||||
这个时候,就需要强制让线程 2的读取指令一定等到写入指令彻底完成之后再执行,避免使用 CPU 缓存。Java 提供了一个 volatile 关键字实现这个能力,只需要这样:
|
||||
|
||||
volatile int lock = 0;
|
||||
|
||||
|
||||
就可以避免从读取不到对lock的写入问题。
|
||||
|
||||
19 | 乐观锁、区块链:除了上锁还有哪些并发控制方法?
|
||||
|
||||
【问题】举例各 2 个悲观锁和乐观锁的应用场景?
|
||||
|
||||
【解析】 乐观锁、悲观锁都能够实现避免竞争条件,实现数据的一致性。 比如减少库存的操作,无论是乐观锁、还是悲观锁都能保证最后库存算对(一致性)。 但是对于并发减库存的各方来说,体验是不一样的。悲观锁要求各方排队等待。 乐观锁,希望各方先各自进步。所以进步耗时较长,合并耗时较短的应用,比较适合乐观锁。 比如协同创作(写文章、视频编辑、写程序等),协同编辑(比如共同点餐、修改购物车、共同编辑商品、分布式配置管理等),非常适合乐观锁,因为这些操作需要较长的时间进步(比如写文章要思考、配置管理可能会连续修改多个配置)。乐观锁可以让多个协同方不急于合并自己的版本,可以先 focus 在进步上。
|
||||
|
||||
相反,悲观锁适用在进步耗时较短的场景,比如锁库存刚好是进步(一次库存计算)耗时少的场景。这种场景使用乐观锁,不但没有足够的收益,同时还会导致各个等待方(线程、客户端等)频繁读取库存——而且还会面临缓存一致性的问题(类比内存一致性问题)。这种进步耗时短,频繁同步的场景,可以考虑用悲观锁。类似的还有银行的交易,订单修改状态等。
|
||||
|
||||
再比如抢购逻辑,就不适合乐观锁。抢购逻辑使用乐观锁会导致大量线程频繁读取缓存确认版本(类似 cas 自旋锁),这种情况下,不如用队列(悲观锁实现)。
|
||||
|
||||
综上:有一个误区就是悲观锁对冲突持有悲观态度,所以性能低;乐观锁,对冲突持有乐观态度,鼓励线程进步,因此性能高。 这个不能一概而论,要看具体的场景。最后补充一下,悲观锁性能最高的一种实现就是阻塞队列,你可以参考 Java 的 7 种继承于 BlockingQueue 阻塞队列类型。
|
||||
|
||||
20 | 线程的调度:线程调度都有哪些方法?
|
||||
|
||||
【问题】用你最熟悉的语言模拟分级队列调度的模型?
|
||||
|
||||
【解析】 我用 Java 实现了一个简单的 yield 框架。 没有到协程的级别,但是也初具规模。考虑到协程实现需要更复杂一些,所以我用 PriorityQueue 来放高优任务;然后我用 LinkedList 来作为放普通任务的队列。Java 语言中的add和remove方法刚好构成了入队和出队操作。
|
||||
|
||||
private PriorityQueue<Task> urgents;
|
||||
|
||||
private ArrayList<LinkedList<Task>> multLevelQueues;
|
||||
|
||||
|
||||
我实现了一个submit方法用于提交任务,代码如下:
|
||||
|
||||
var scheduler = new MultiLevelScheduler();
|
||||
|
||||
scheduler.submit((IYieldFunction yield) -> {
|
||||
|
||||
System.out.println("Urgent");
|
||||
|
||||
}, 10);
|
||||
|
||||
|
||||
普通任务我的程序中默认是 3 级队列。提交的任务优先级小于 100 的会放入紧急队列。每个任务就是一个简单的函数。我构造了一个 next() 方法用于决定下一个执行的任务,代码如下:
|
||||
|
||||
private Task next(){
|
||||
|
||||
if(this.urgents.size() > 0) {
|
||||
|
||||
return this.urgents.remove();
|
||||
|
||||
} else {
|
||||
|
||||
for(int i = 0; i < this.level; i++) {
|
||||
|
||||
var queue = this.multLevelQueues.get(i);
|
||||
|
||||
if(queue.size() > 0) {
|
||||
|
||||
return queue.remove();
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
}
|
||||
|
||||
|
||||
先判断高优队列,然后再逐级看普通队列。
|
||||
|
||||
执行的程序就是递归调用 runNext() 方法,代码如下:
|
||||
|
||||
private void runNext(){
|
||||
|
||||
var nextTask = this.next();
|
||||
|
||||
if(nextTask == null) {return;}
|
||||
|
||||
if(nextTask.isYield()) {
|
||||
|
||||
return;
|
||||
|
||||
}
|
||||
|
||||
nextTask.run(() -> {
|
||||
|
||||
// yiled 内容……省略
|
||||
|
||||
});
|
||||
|
||||
this.runNext();
|
||||
|
||||
}
|
||||
|
||||
|
||||
上面程序中,如果当前任务在yield状态,那么终止当前程序。yield相当于函数调用,从yield函数调用中返回相当于继续执行。yield相当于任务主动让出执行时间。使用yield模式不需要线程切换,可以最大程度利用单核效率。
|
||||
|
||||
最后是yield的实现,nextTask.run 后面的匿名函数就是yield方法,它像一个调度程序一样,先简单保存当前的状态,然后将当前任务放到对应的位置(重新入队,或者移动到下一级队列)。如果当前任务是高优任务,yield程序会直接返回,因为高优任务没有必要yield,代码如下:
|
||||
|
||||
nextTask.run(() -> {
|
||||
|
||||
if(nextTask.level == -1) {
|
||||
|
||||
// high-priority forbid yield
|
||||
|
||||
return;
|
||||
|
||||
}
|
||||
|
||||
nextTask.setYield(true);
|
||||
|
||||
if(nextTask.level < this.level - 1) {
|
||||
|
||||
multLevelQueues.get(nextTask.level + 1).add(nextTask);
|
||||
|
||||
nextTask.setLevel(nextTask.level + 1);
|
||||
|
||||
} else {
|
||||
|
||||
multLevelQueues.get(nextTask.level).add(nextTask);
|
||||
|
||||
}
|
||||
|
||||
this.runNext();
|
||||
|
||||
});
|
||||
|
||||
|
||||
下面是完成的程序,你可以在自己的 IDE 中尝试。
|
||||
|
||||
package test;
|
||||
|
||||
import java.util.ArrayList;
|
||||
|
||||
import java.util.LinkedList;
|
||||
|
||||
import java.util.PriorityQueue;
|
||||
|
||||
import java.util.concurrent.locks.LockSupport;
|
||||
|
||||
import java.util.function.Function;
|
||||
|
||||
public class MultiLevelScheduler {
|
||||
|
||||
/**
|
||||
|
||||
* High-priority
|
||||
|
||||
*/
|
||||
|
||||
private PriorityQueue<Task> urgents;
|
||||
|
||||
private ArrayList<LinkedList<Task>> multLevelQueues;
|
||||
|
||||
/**
|
||||
|
||||
* Levels of Scheduler
|
||||
|
||||
*/
|
||||
|
||||
private int level = 3;
|
||||
|
||||
public MultiLevelScheduler(){
|
||||
|
||||
this.init();
|
||||
|
||||
}
|
||||
|
||||
public MultiLevelScheduler(int level) {
|
||||
|
||||
this.level = level;
|
||||
|
||||
this.init();
|
||||
|
||||
}
|
||||
|
||||
private void init(){
|
||||
|
||||
urgents = new PriorityQueue<>();
|
||||
|
||||
multLevelQueues = new ArrayList<>();
|
||||
|
||||
for(int i = 0; i < this.level; i++) {
|
||||
|
||||
multLevelQueues.add(new LinkedList<Task>());
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
|
||||
interface IYieldFunction {
|
||||
|
||||
void yield();
|
||||
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
|
||||
interface ITask{
|
||||
|
||||
void run(IYieldFunction yieldFunction);
|
||||
|
||||
}
|
||||
|
||||
class Task implements Comparable<Task>{
|
||||
|
||||
int level = -1;
|
||||
|
||||
ITask task;
|
||||
|
||||
int priority;
|
||||
|
||||
private boolean yield;
|
||||
|
||||
public Task(ITask task, int priority) {
|
||||
|
||||
this.task = task;
|
||||
|
||||
this.priority = priority;
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
public int compareTo(Task o) {
|
||||
|
||||
return this.priority - o.priority;
|
||||
|
||||
}
|
||||
|
||||
public int getLevel() {
|
||||
|
||||
return level;
|
||||
|
||||
}
|
||||
|
||||
public void setLevel(int level) {
|
||||
|
||||
this.level = level;
|
||||
|
||||
}
|
||||
|
||||
public void run(IYieldFunction f) {
|
||||
|
||||
this.task.run(f);
|
||||
|
||||
}
|
||||
|
||||
public void setYield(boolean yield) {
|
||||
|
||||
this.yield = yield;
|
||||
|
||||
}
|
||||
|
||||
public boolean isYield() {
|
||||
|
||||
return yield;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public void submit(ITask itask, int priority) {
|
||||
|
||||
var task = new Task(itask, priority);
|
||||
|
||||
if(priority >= 100) {
|
||||
|
||||
this.multLevelQueues.get(0).add(task);
|
||||
|
||||
task.setLevel(0);
|
||||
|
||||
} else {
|
||||
|
||||
this.urgents.add(task);
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public void submit(ITask t) {
|
||||
|
||||
this.submit(t, 100);
|
||||
|
||||
}
|
||||
|
||||
private Task next(){
|
||||
|
||||
if(this.urgents.size() > 0) {
|
||||
|
||||
return this.urgents.remove();
|
||||
|
||||
} else {
|
||||
|
||||
for(int i = 0; i < this.level; i++) {
|
||||
|
||||
var queue = this.multLevelQueues.get(i);
|
||||
|
||||
if(queue.size() > 0) {
|
||||
|
||||
return queue.remove();
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
}
|
||||
|
||||
private void runNext(){
|
||||
|
||||
var nextTask = this.next();
|
||||
|
||||
if(nextTask == null) {return;}
|
||||
|
||||
if(nextTask.isYield()) {
|
||||
|
||||
return;
|
||||
|
||||
}
|
||||
|
||||
nextTask.run(() -> {
|
||||
|
||||
if(nextTask.level == -1) {
|
||||
|
||||
// high-priority forbid yield
|
||||
|
||||
return;
|
||||
|
||||
}
|
||||
|
||||
nextTask.setYield(true);
|
||||
|
||||
if(nextTask.level < this.level - 1) {
|
||||
|
||||
multLevelQueues.get(nextTask.level + 1).add(nextTask);
|
||||
|
||||
nextTask.setLevel(nextTask.level + 1);
|
||||
|
||||
} else {
|
||||
|
||||
multLevelQueues.get(nextTask.level).add(nextTask);
|
||||
|
||||
}
|
||||
|
||||
this.runNext();
|
||||
|
||||
});
|
||||
|
||||
this.runNext();
|
||||
|
||||
}
|
||||
|
||||
public void start() throws InterruptedException {
|
||||
|
||||
this.runNext();
|
||||
|
||||
}
|
||||
|
||||
public static void main(String[] argv) throws InterruptedException {
|
||||
|
||||
var scheduler = new MultiLevelScheduler();
|
||||
|
||||
scheduler.submit((IYieldFunction yield) -> {
|
||||
|
||||
System.out.println("Urgent");
|
||||
|
||||
}, 10);
|
||||
|
||||
scheduler.submit((IYieldFunction yield) -> {
|
||||
|
||||
System.out.println("Most Urgent");
|
||||
|
||||
}, 0);
|
||||
|
||||
scheduler.submit((IYieldFunction yield) -> {
|
||||
|
||||
System.out.println("A1");
|
||||
|
||||
yield.yield();
|
||||
|
||||
System.out.println("A2");
|
||||
|
||||
});
|
||||
|
||||
scheduler.submit((IYieldFunction yield) -> {
|
||||
|
||||
System.out.println("B");
|
||||
|
||||
});
|
||||
|
||||
scheduler.submit((IYieldFunction f) -> {
|
||||
|
||||
System.out.println("C");
|
||||
|
||||
});
|
||||
|
||||
scheduler.start();
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
最后是执行结果如下:
|
||||
|
||||
Most Urgent
|
||||
|
||||
Urgent
|
||||
|
||||
A1
|
||||
|
||||
B
|
||||
|
||||
C
|
||||
|
||||
A2
|
||||
|
||||
Process finished with exit code 0
|
||||
|
||||
我们看到结果中任务 1 发生了yield在打印 A2 之前交出了控制权导致任务 B,C 先被执行。如果你想在 yield 出上增加定时的功能,可以考虑 yield 发生后将任务移出队列,并在定时结束后重新插入回来。
|
||||
|
||||
21 | 哲学家就餐问题:什么情况下会触发饥饿和死锁?
|
||||
|
||||
【问题】如果哲学家就餐问题拿起叉子、放下叉子,只需要微小的时间,主要时间开销集中在 think 需要计算资源(CPU 资源)上,那么使用什么模型比较合适?
|
||||
|
||||
【解析】 哲学家就餐问题最多允许两组哲学家就餐,如果开销集中在计算上,那么只要同时有两组哲学家可以进入临界区即可。不考虑 I/O 成本,问题就很简化了,也失去了讨论的意义。比如简单要求哲学家们同时拿起左右手的叉子的做法就可以达到 2 组哲学家同时进餐。
|
||||
|
||||
22 | 进程间通信: 进程间通信都有哪些方法?
|
||||
|
||||
【问题】还有哪些我没有讲到的进程间通信方法?
|
||||
|
||||
【解析】 我看到有同学提到了 Android 系统的 OpenBinder 机制——允许不同进程的线程间调用(类似 RPC)。底层是 Linux 的文件系统和内核对 Binder 的驱动。
|
||||
|
||||
我还有没讲到的进程间的通信方法,比如说:
|
||||
|
||||
|
||||
使用数据库
|
||||
使用普通文件
|
||||
还有一种是信号,一个进程可以通过操作系统提供的信号。举个例子,假如想给某个进程(pid=9999)发送一个 USR1 信号,那么可以用:
|
||||
|
||||
|
||||
kill -s USR1 9999
|
||||
|
||||
|
||||
进程 9999 可以通过写程序接收这个信号。 上述过程除了用kill指令外,还可以调用操作系统 API 完成。
|
||||
|
||||
23 | 分析服务的特性:我的服务应该开多少个进程、多少个线程?
|
||||
|
||||
【问题】如果磁盘坏了,通常会是怎样的情况?
|
||||
|
||||
【解析】 磁盘如果彻底坏了,服务器可能执行程序报错,无法写入,甚至死机。这些情况非常容易发现。而比较不容易观察的是坏道,坏道是磁盘上某个小区域数据无法读写了。有可能是硬损坏,就是物理损坏了,相当于永久损坏。也有可能是软损坏,比如数据写乱了。导致磁盘坏道的原因很多,比如电压不稳、灰尘、磁盘质量等问题。
|
||||
|
||||
磁盘损坏之前,往往还伴随性能整体的下降;坏道也会导致读写错误。所以在出现问题前,通常是可以在监控系统中观察到服务器性能指标变化的。比如 CPU 使用量上升,I/O Wait 增多,相同并发量下响应速度变慢等。
|
||||
|
||||
如果在工作中你怀疑磁盘坏了,可以用下面这个命令检查坏道:
|
||||
|
||||
sudo badblocks -v /dev/sda5
|
||||
|
||||
|
||||
我的机器上是 /dev/sda5,你可以用df命令查看自己的文件系统。
|
||||
|
||||
总结
|
||||
|
||||
这个模块我们完整的学习了进程和多线程,讨论了多线程中最底层,最重要的若干问题,比如原子操作、锁、调度等。如果你还想深入学习,可以在课下去学习这几块知识。
|
||||
|
||||
|
||||
一个是同步队列,这是实战中非常重要的一类并发数据结构,能够帮助你解决生产者消费者问题。
|
||||
另一个是无锁设计,目的是提高程序的并发能力,尽可能地让更多的线程获得进步。
|
||||
最后一块就是分布式领域,当你熟悉了操作系统知识后,分布式领域的知识能够给到你更多的场景和启发。
|
||||
|
||||
|
||||
好的,进程和多线程部分就告一段落。接下来,我们将开始内存管理相关知识,请和我一起来学习“模块五:内存管理”吧。
|
||||
|
||||
|
||||
|
||||
|
172
专栏/重学操作系统-完/23分析服务的特性:我的服务应该开多少个进程、多少个线程?.md
Normal file
172
专栏/重学操作系统-完/23分析服务的特性:我的服务应该开多少个进程、多少个线程?.md
Normal file
@@ -0,0 +1,172 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
23 分析服务的特性:我的服务应该开多少个进程、多少个线程?
|
||||
在平时工作中,你应该经常会遇到自己设计的服务即将上线,这就需要从整体评估各项指标,比如应该开多少个容器、需要多少 CPU 呢?另一方面,应该开多少个线程、多少个进程呢?——如果结合服务特性、目标并发量、目标吞吐量、用户可以承受的延迟等分析,又应该如何调整各种参数?
|
||||
|
||||
资源分配多了,CPU、内存等资源会产生资源闲置浪费。资源给少了,则服务不能正常工作,甚至雪崩。因此这里就产生了一个性价比问题——这一讲,就以“我的服务应该开多少个进程、多少个线程”为引,我们一起讨论如何更好地利用系统的资源。
|
||||
|
||||
计算密集型和 I/O 密集型
|
||||
|
||||
通常我们会遇到两种任务,一种是计算、一种是 I/O。
|
||||
|
||||
计算,就是利用 CPU 处理算数运算。比如深度神经网络(Deep Neural Networks),需要大量的计算来计算神经元的激活和传播。再比如,根据营销规则计算订单价格,虽然每一个订单只需要少量的计算,但是在并发高的时候,所有订单累计加起来就需要大量计算。如果一个应用的主要开销在计算上,我们称为计算密集型。
|
||||
|
||||
再看看 I/O 密集型,I/O 本质是对设备的读写。读取键盘的输入是 I/O,读取磁盘(SSD)的数据是 I/O。通常 CPU 在设备 I/O 的过程中会去做其他的事情,当 I/O 完成,设备会给 CPU 一个中断,告诉 CPU 响应 I/O 的结果。比如说从硬盘读取数据完成了,那么硬盘给 CPU 一个中断。如果操作对 I/O 的依赖强,比如频繁的文件操作(写日志、读写数据库等),可以看作I/O 密集型。
|
||||
|
||||
你可能会有一个疑问,读取硬盘数据到内存中这个过程,CPU 需不需要一个个字节处理?
|
||||
|
||||
通常是不用的,因为在今天的计算机中有一个叫作 Direct Memory Access(DMA)的模块,这个模块允许硬件设备直接通过 DMA 写内存,而不需要通过 CPU(占用 CPU 资源)。
|
||||
|
||||
|
||||
|
||||
很多情况下我们没法使用 DMA,比如说你想把一个数组拷贝到另一个数组内,执行的 memcpy 函数内部实现就是一个个 byte 拷贝,这种情况也是一种CPU 密集的操作。
|
||||
|
||||
可见,区分是计算密集型还是 I/O 密集型这件事比较复杂。按说查询数据库是一件 I/O 密集型的事情,但是如果存储设备足够好,比如用了最好的固态硬盘阵列,I/O 速度很快,反而瓶颈会在计算上(对缓存的搜索耗时成为主要部分)。因此,需要一些可衡量指标,来帮助我们确认应用的特性。
|
||||
|
||||
衡量 CPU 的工作情况的指标
|
||||
|
||||
我们先来看一下 CPU 关联的指标。如下图所示:CPU 有 2 种状态,忙碌和空闲。此外,CPU 的时间还有一种被偷走的情况。
|
||||
|
||||
|
||||
|
||||
忙碌就是 CPU 在执行有意义的程序,空闲就是 CPU 在执行让 CPU 空闲(空转)的指令。通常让 CPU 空转的指令能耗更低,因此让 CPU 闲置时,我们会使用特别的指令,最终效果和让 CPU 计算是一样的,都可以把 CPU 执行时间填满,只不过这类型指令能耗低一些而已。除了忙碌和空闲,CPU 的时间有可能被宿主偷走,比如一台宿主机器上有 10 个虚拟机,宿主可以偷走给任何一台虚拟机的时间。
|
||||
|
||||
如上图所示,CPU 忙碌有 3 种情况:
|
||||
|
||||
|
||||
执行用户空间程序;
|
||||
执行内核空间程序;
|
||||
执行中断程序。
|
||||
|
||||
|
||||
CPU 空闲有 2 种情况。
|
||||
|
||||
|
||||
CPU 无事可做,执行空闲指令(注意,不能让 CPU 停止工作,而是执行能耗更低的空闲指令)。
|
||||
CPU 因为需要等待 I/O 而空闲,比如在等待磁盘回传数据的中断,这种我们称为 I/O Wait。
|
||||
|
||||
|
||||
下图是我们执行 top 指令看到目前机器状态的快照,接下来我们仔细研究一下这些指标的含义:
|
||||
|
||||
|
||||
|
||||
如上图所示,你可以细看下 %CPU(s) 开头那一行(第 3 行):
|
||||
|
||||
|
||||
us(user),即用户空间 CPU 使用占比。
|
||||
sy(system),即内核空间 CPU 使用占比。
|
||||
ni(nice),nice 是 Unix 系操作系统控制进程优先级用的。-19 是最高优先级, 20 是最低优先级。这里代表了调整过优先级的进程的 CPU 使用占比。
|
||||
id(idle),闲置的 CPU 占比。
|
||||
wa(I/O Wait),I/O Wait 闲置的 CPU 占比。
|
||||
hi(hardware interrupts),响应硬件中断 CPU 使用占比。
|
||||
si(software interrrupts),响应软件中断 CPU 使用占比。
|
||||
st(stolen),如果当前机器是虚拟机,这个指标代表了宿主偷走的 CPU 时间占比。对于一个宿主多个虚拟机的情况,宿主可以偷走任何一台虚拟机的 CPU 时间。
|
||||
|
||||
|
||||
上面我们用 top 看的是一个平均情况,如果想看所有 CPU 的情况可以 top 之后,按一下1键。结果如下图所示:
|
||||
|
||||
|
||||
|
||||
当然,对性能而言,CPU 数量也是一个重要因素。可以看到我这台虚拟机一共有 16 个核心。
|
||||
|
||||
负载指标
|
||||
|
||||
上面的指标非常多,在排查问题的时候,需要综合分析。其实还有一些更简单的指标,比如上图中 top 指令返回有一项叫作load average——平均负载。 负载可以理解成某个时刻正在排队执行的进程数除以 CPU 核数。平均负载需要多次采样求平均值。 如果这个值大于1,说明 CPU 相当忙碌。因此如果你想发现问题,可以先检查这个指标。
|
||||
|
||||
具体来说,如果平均负载很高,CPU 的 I/O Wait 也很高, 那么就说明 CPU 因为需要大量等待 I/O 无法处理完成工作。产生这个现象的原因可能是:线上服务器打日志太频繁,读写数据库、网络太频繁。你可以考虑进行批量读写优化。
|
||||
|
||||
到这里,你可能会有一个疑问:为什么批量更快呢?我们知道一次写入 1M 的数据,就比写一百万次一个 byte 快。因为前者可以充分利用 CPU 的缓存、复用发起写操作程序的连接和缓冲区等。
|
||||
|
||||
如果想看更多load average,你可以看/proc/loadavg文件。
|
||||
|
||||
通信量(Traffic)
|
||||
|
||||
如果怀疑瓶颈发生在网络层面,或者想知道当前网络状况。可以查看/proc/net/dev,下图是在我的虚拟机上的查询结果:
|
||||
|
||||
|
||||
|
||||
我们来一起看一下上图中的指标。表头分成了 3 段:
|
||||
|
||||
|
||||
Interface(网络接口),可以理解成网卡
|
||||
Receive:接收的数据
|
||||
Transmit:发送的数据
|
||||
|
||||
|
||||
然后再来看具体的一些参数:
|
||||
|
||||
|
||||
byte 是字节数
|
||||
package 是封包数
|
||||
erros 是错误数
|
||||
drop 是主动丢弃的封包,比如说时间窗口超时了
|
||||
fifo: FIFO 缓冲区错误(如果想了解更多可以关注我即将推出的《计算机网络》专栏)
|
||||
frame: 底层网络发生了帧错误,代表数据出错了
|
||||
|
||||
|
||||
如果你怀疑自己系统的网络有故障,可以查一下通信量部分的参数,相信会有一定的收获。
|
||||
|
||||
衡量磁盘工作情况
|
||||
|
||||
有时候 I/O 太频繁导致磁盘负载成为瓶颈,这个时候可以用iotop指令看一下磁盘的情况,如图所示:
|
||||
|
||||
|
||||
|
||||
上图中是磁盘当前的读写速度以及排行较靠前的进程情况。
|
||||
|
||||
另外,如果磁盘空间不足,可以用df指令:
|
||||
|
||||
|
||||
|
||||
其实 df 是按照挂载的文件系统计算空间。图中每一个条目都是一个文件系统。有的文件系统直接挂在了一个磁盘上,比如图中的/dev/sda5挂在了/上,因此这样可以看到各个磁盘的使用情况。
|
||||
|
||||
如果想知道更细粒度的磁盘 I/O 情况,可以查看/proc/diskstats文件。 这里有 20 多个指标我就不细讲了,如果你将来怀疑自己系统的 I/O 有问题,可以查看这个文件,并阅读相关手册。
|
||||
|
||||
监控平台
|
||||
|
||||
Linux 中有很多指令可以查看服务器当前的状态,有 CPU、I/O、通信、Nginx 等维度。如果去记忆每个指令自己搭建监控平台,会非常复杂。这里你可以用市面上别人写好的开源系统帮助你收集这些资料。 比如 Taobao System Activity Report(tsar)就是一款非常好用的工具。它集成了大量诸如上面我们使用的工具,并且帮助你定时收集服务器情况,还能记录成日志。你可以用 logstash 等工具,及时将日志收集到监控、分析服务中,比如用 ELK 技术栈。
|
||||
|
||||
决定进程/线程数量
|
||||
|
||||
最后我们讲讲如何决定线程、进程数量。 上面观察指标是我们必须做的一件事情,通过观察上面的指标,可以对我们开发的应用有一个基本的认识。
|
||||
|
||||
下面请你思考一个问题:如果线程或进程数量 = CPU 核数,是不是一个好的选择?
|
||||
|
||||
有的应用不提供线程,比如 PHP 和 Node.js。
|
||||
|
||||
Node.js 内部有一个事件循环模型,这个模型可以理解成协程(Coroutine),相当于大量的协程复用一个进程,可以达到比线程池更高的效率(减少了线程切换)。PHP 模型相对则差得多。Java 是一个多线程的模型,线程和内核线程对应比 1:1;Go 有轻量级线程,多个轻量级线程复用一个内核级线程。
|
||||
|
||||
以 Node.js 为例,如果现在是 8 个核心,那么开 8 个 Node 进程,是不是就是最有效利用 CPU 的方案呢? 乍一看——8 个核、8 个进程,每个进程都可以使用 1 个核,CPU 利用率很高——其实不然。 你不要忘记,CPU 中会有一部分闲置时间是 I/O Wait,这个时候 CPU 什么也不做,主要时间用于等待 I/O。
|
||||
|
||||
假设我们应用执行的期间只用 50% CPU 的执行时间,其他 50% 是 I/O Wait。那么 1 个 CPU 同时就可以执行两个进程/线程。
|
||||
|
||||
我们考虑一个更一般的模型,如果你的应用平均 I/O 时间占比是 P,假设现在内存中有 n 个这样的线程,那么 CPU 的利用率是多少呢?
|
||||
|
||||
假设我们观察到一个应用 (进程),I/O 时间占比是 P,那么可以认为这个进程等待 I/O 的概率是 P。那么如果有 n 个这样的线程,n 个线程都在等待 I/O 的概率是Pn。而满负荷下,CPU 的利用率就是 CPU 不能空转——也就是不能所有进程都在等待 I/O。因此 CPU 利用率 = 1 -Pn。
|
||||
|
||||
理论上,如果 P = 50%,两个这样的进程可以达到满负荷。 但是从实际出发,何时运行线程是一个分时的调度行为,实际的 CPU 利用率还要看开了多少个这样的线程,如果是 2 个,那么还是会有一部分闲置资源。
|
||||
|
||||
因此在实际工作中,开的线程、进程数往往是超过 CPU 核数的。你可能会问,具体是多少最好呢?——这里没有具体的算法,要以实际情况为准。比如:你先以 CPU 核数 3 倍的线程数开始,然后进行模拟真实线上压力的测试,分析压测的结果。
|
||||
|
||||
|
||||
如果发现整个过程中,瓶颈在 CPU,比如load average很高,那么可以考虑优化 I/O Wait,让 CPU 有更多时间计算。
|
||||
当然,如果 I/O Wait 优化不动了,算法都最优了,就是磁盘读写速度很高达到瓶颈,可以考虑延迟写、延迟读等等技术,或者优化减少读写。
|
||||
如果发现 idle 很高,CPU 大面积闲置,就可以考虑增加线程。
|
||||
|
||||
|
||||
总结
|
||||
|
||||
那么通过这节课的学习,你现在可以尝试来回答本节关联的面试题目:我的服务应该开多少个进程、多少个线程?
|
||||
|
||||
【解析】 计算密集型一般接近核数,如果负载很高,建议留一个内核专门给操作系统。I/O 密集型一般都会开大于核数的线程和进程。 但是无论哪种模型,都需要实地压测,以压测结果分析为准;另一方面,还需要做好监控,观察服务在不同并发场景的情况,避免资源耗尽。
|
||||
|
||||
然后具体语言的特性也要考虑,Node.js 每个进程内部实现了大量类似协程的执行单元,因此 Node.js 即便在 I/O 密集型场景下也可以考虑长期使用核数 -1 的进程模型。而 Java 是多线程模型,线程池通常要大于核数才能充分利用 CPU 资源。
|
||||
|
||||
所以核心就一句,眼见为实,上线前要进行压力测试。
|
||||
|
||||
|
||||
|
||||
|
155
专栏/重学操作系统-完/24虚拟内存:一个程序最多能使用多少内存?.md
Normal file
155
专栏/重学操作系统-完/24虚拟内存:一个程序最多能使用多少内存?.md
Normal file
@@ -0,0 +1,155 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
24 虚拟内存 :一个程序最多能使用多少内存?
|
||||
这个模块我们开始学习操作系统的内存管理,接下来我会先用 3 节课讲解操作系统对内存管理的原理。因为内存资源总是稀缺的,即便在拥有百 G 内存的机器上,我们都可以轻易把内存填满。为了解决这个问题,就需要用到虚拟化技术。
|
||||
|
||||
|
||||
因此,本模块前面 3 讲将围绕虚拟化技术展开:第 24 讲介绍设计思想;第 25 讲介绍优化手段;第 26 讲挑选了对你工作比较有帮助的缓存置换算法深入讲解。
|
||||
|
||||
后面的第 27、28 讲将围绕内存回收(GC)讲解,GC 是面试的高频重点知识,同时也是程序员日常开发需要理解的部分。学习 GC 有助于你优化你开发应用的性能,特别是遇到内存不够用不会束手无策。
|
||||
|
||||
今天我们先学习内存的虚拟化技术。
|
||||
|
||||
内存是稀缺的,随着应用使用内存也在膨胀。当程序越来复杂,进程对内存的需求会越来越大。从安全角度考虑,进程间使用内存需要隔离。另外还有一些特殊场景,比如说,我在“模块四加餐”中提到的内存一致性问题,存在不希望 CPU 进行缓存的场景。 这个时候,有一个虚拟化层承接各种各样的诉求,统一进行处理,就会有很大的优势。
|
||||
|
||||
还有一个大家普遍关心的问题,也是这节课我给大家带来的面试题:一个程序最多能使用多少内存?
|
||||
|
||||
要回答这个问题,就需要对内存的虚拟化有一定的认识。接下来就请你带着问题,和我一起学习“内存的虚拟化技术”。
|
||||
|
||||
为什么内存不够用?
|
||||
|
||||
要理解一个技术,就必须理解它为何而存在。总体来说,虚拟化技术是为了解决内存不够用的问题,那么内存为何不够用呢?
|
||||
|
||||
主要是因为程序越来越复杂。比如说我现在给你录音的机器上就有 200 个进程,目前内存的消耗是 21G,我的内存是 64G 的,但是多开一些程序还是会被占满。 另外,如果一个程序需要使用大的内存,比如 1T,是不是应该报错?如果报错,那么程序就会不好写,程序员必须小心翼翼地处理内存的使用,避免超过允许的内存使用阈值。以上提到的这些都是需要解决的问题,也是虚拟化技术存在的价值和意义。
|
||||
|
||||
那么如何来解决这些问题呢? 历史上有过不少的解决方案,但最终沉淀下的是虚拟化技术。接下来我为你介绍一种历史上存在过的 Swap 技术以及虚拟化技术。
|
||||
|
||||
交换(Swap)技术
|
||||
|
||||
Swap 技术允许一部分进程使用内存,不使用内存的进程数据先保存在磁盘上。注意,这里提到的数据,是完整的进程数据,包括正文段(程序指令)、数据段、堆栈段等。轮到某个进程执行的时候,尝试为这个进程在内存中找到一块空闲的区域。如果空间不足,就考虑把没有在执行的进程交换(Swap)到磁盘上,把空间腾挪出来给需要的进程。
|
||||
|
||||
|
||||
|
||||
上图中,内存被拆分成多个区域。 内核作为一个程序也需要自己的内存。另外每个进程独立得到一个空间——我们称为地址空间(Address Space)。你可以认为地址空间是一块连续分配的内存块。每个进程在不同地址空间中工作,构成了一个原始的虚拟化技术。
|
||||
|
||||
比如:当进程 A 想访问地址 100 的时候,实际上访问的地址是基于地址空间本身位置(首字节地址)计算出来的。另外,当进程 A 执行时,CPU 中会保存它地址空间的开始位置和结束位置,当它想访问超过地址空间容量的地址时,CPU 会检查然后报错。
|
||||
|
||||
上图描述的这种方法,是一种比较原始的虚拟化技术,进程使用的是基于地址空间的虚拟地址。但是这种方案有很多明显的缺陷,比如:
|
||||
|
||||
|
||||
碎片问题:上图中我们看到进程来回分配、回收交换,内存之间会产生很多缝隙。经过反反复复使用,内存的情况会变得十分复杂,导致整体性能下降。
|
||||
频繁切换问题:如果进程过多,内存较小,会频繁触发交换。
|
||||
|
||||
|
||||
你可以先思考这两个问题的解决方案,接下来我会带你进行一些更深入地思考——首先重新 Review 下我们的设计目标。
|
||||
|
||||
|
||||
隔离:每个应用有自己的地址空间,互不影响。
|
||||
性能:高频使用的数据保留在内存中、低频使用的数据持久化到磁盘上。
|
||||
程序好写(降低程序员心智负担):让程序员不用关心底层设施。
|
||||
|
||||
|
||||
现阶段,Swap 技术已经初步解决了问题 1。关于问题 2,Swap 技术在性能上存在着碎片、频繁切换等明显劣势。关于问题 3,使用 Swap 技术,程序员需要清楚地知道自己的应用用多少内存,并且小心翼翼地使用内存,避免需要重新申请,或者研发不断扩容的算法——这让程序心智负担较大。
|
||||
|
||||
经过以上分析,需要更好的解决方案,就是我们接下来要学习的虚拟化技术。
|
||||
|
||||
虚拟内存
|
||||
|
||||
虚拟化技术中,操作系统设计了虚拟内存(理论上可以无限大的空间),受限于 CPU 的处理能力,通常 64bit CPU,就是 264 个地址。
|
||||
|
||||
|
||||
虚拟化技术中,应用使用的是虚拟内存,操作系统管理虚拟内存和真实内存之间的映射。操作系统将虚拟内存分成整齐小块,每个小块称为一个页(Page)。之所以这样做,原因主要有以下两个方面。
|
||||
|
||||
|
||||
一方面应用使用内存是以页为单位,整齐的页能够避免内存碎片问题。
|
||||
另一方面,每个应用都有高频使用的数据和低频使用的数据。这样做,操作系统就不必从应用角度去思考哪个进程是高频的,仅需思考哪些页被高频使用、哪些页被低频使用。如果是低频使用,就将它们保存到硬盘上;如果是高频使用,就让它们保留在真实内存中。
|
||||
|
||||
|
||||
如果一个应用需要非常大的内存,应用申请的是虚拟内存中的很多个页,真实内存不一定需要够用。
|
||||
|
||||
页(Page)和页表
|
||||
|
||||
接下来,我们详细讨论下这个设计。操作系统将虚拟内存分块,每个小块称为一个页(Page);真实内存也需要分块,每个小块我们称为一个 Frame。Page 到 Frame 的映射,需要一种叫作页表的结构。
|
||||
|
||||
|
||||
上图展示了 Page、Frame 和页表 (PageTable)三者之间的关系。 Page 大小和 Frame 大小通常相等,页表中记录的某个 Page 对应的 Frame 编号。页表也需要存储空间,比如虚拟内存大小为 10G, Page 大小是 4K,那么需要 10G/4K = 2621440 个条目。如果每个条目是 64bit,那么一共需要 20480K = 20M 页表。操作系统在内存中划分出小块区域给页表,并负责维护页表。
|
||||
|
||||
页表维护了虚拟地址到真实地址的映射。每次程序使用内存时,需要把虚拟内存地址换算成物理内存地址,换算过程分为以下 3 个步骤:
|
||||
|
||||
|
||||
通过虚拟地址计算 Page 编号;
|
||||
查页表,根据 Page 编号,找到 Frame 编号;
|
||||
将虚拟地址换算成物理地址。
|
||||
|
||||
|
||||
下面我通过一个例子给你讲解上面这个换算的过程:如果页大小是 4K,假设程序要访问地址:100,000。那么计算过程如下。
|
||||
|
||||
|
||||
页编号(Page Number) = 100,000/4096 = 24 余1619。 24 是页编号,1619 是地址偏移量(Offset)。
|
||||
查询页表,得到 24 关联的 Frame 编号(假设查到 Frame 编号 = 10)。
|
||||
换算:通常 Frame 和 Page 大小相等,替换 Page Number 为 Frame Number 物理地址 = 4096 * 10 + 1619 = 42579。
|
||||
|
||||
|
||||
MMU
|
||||
|
||||
上面的过程发生在 CPU 中一个小型的设备——内存管理单元(Memory Management Unit, MMU)中。如下图所示:
|
||||
|
||||
|
||||
|
||||
当 CPU 需要执行一条指令时,如果指令中涉及内存读写操作,CPU 会把虚拟地址给 MMU,MMU 自动完成虚拟地址到真实地址的计算;然后,MMU 连接了地址总线,帮助 CPU 操作真实地址。
|
||||
|
||||
这样的设计,就不需要在编写应用程序的时候担心虚拟地址到物理地址映射的问题。我们把全部难题都丢给了操作系统——操作系统要确定MMU 可以读懂自己的页表格式。所以,操作系统的设计者要看 MMU 的说明书完成工作。
|
||||
|
||||
难点在于不同 CPU 的 MMU 可能是不同的,因此这里会遇到很多跨平台的问题。解决跨平台问题不但有繁重的工作量,更需要高超的编程技巧,Unix 最初期的移植性(跨平台)是 C 语言作者丹尼斯·里奇实现的。
|
||||
|
||||
学到这里,细心的同学可能会有疑问:MMU 需要查询页表(这是内存操作),而 CPU 执行一条指令通过 MMU 获取内存数据,难道可以容忍在执行一条指令的过程中,发生多次内存读取(查询)操作?难道一次普通的读取操作,还要附加几次查询页表的开销吗?当然不是,这里还有一些高速缓存的设计,这部分我们放到“25 讲”中详细讨论。
|
||||
|
||||
页表条目
|
||||
|
||||
上面我们笼统介绍了页表将 Page 映射到 Frame。那么,页表中的每一项(页表条目)长什么样子呢?下图是一个页表格式的一个演示。
|
||||
|
||||
|
||||
页表条目本身的编号可以不存在页表中,而是通过偏移量计算。 比如地址 100,000 的编号,可以用 100,000 除以页大小确定。
|
||||
|
||||
|
||||
Absent(“在”)位,是一个 bit。0 表示页的数据在磁盘中(不再内存中),1 表示在内存中。如果读取页表发现 Absent = 0,那么会触发缺页中断,去磁盘读取数据。
|
||||
Protection(保护)字段可以实现成 3 个 bit,它决定页表用于读、写、执行。比如 000 代表什么都不能做,100 代表只读等。
|
||||
Reference(访问)位,代表这个页被读写过,这个记录对回收内存有帮助。
|
||||
Dirty(“脏”)位,代表页的内容被修改过,如果 Dirty =1,那么意味着页面必须回写到磁盘上才能置换(Swap)。如果 Dirty = 0,如果需要回收这个页,可以考虑直接丢弃它(什么也不做,其他程序可以直接覆盖)。
|
||||
Caching(缓存位),描述页可不可以被 CPU 缓存。CPU 缓存会造成内存不一致问题,在上个模块的加餐中我们讨论了内存一致性问题,具体你可以参考“模块四”的加餐内容。
|
||||
Frame Number(Frame 编号),这个是真实内存的位置。用 Frame 编号乘以页大小,就可以得到 Frame 的基地址。
|
||||
|
||||
|
||||
在 64bit 的系统中,考虑到 Absent、Protection 等字段需要占用一定的位,因此不能将 64bit 都用来描述真实地址。但是 64bit 可以寻址的空间已经远远超过了 EB 的级别(1EB = 220TB),这已经足够了。在真实世界,我们还造不出这么大的内存呢。
|
||||
|
||||
大页面问题
|
||||
|
||||
最后,我们讨论一下大页面的问题。假设有一个应用,初始化后需要 12M 内存,操作系统页大小是 4K。那么应该如何设计呢?
|
||||
|
||||
为了简化模型,下图中,假设这个应用只有 3 个区域(3 个段)——正文段(程序)、数据段(常量、全局变量)、堆栈段。一开始我们 3 个段都分配了 4M 的空间。随着程序执行,堆栈段的空间会继续增加,上不封顶。
|
||||
|
||||
|
||||
上图中,进程内部需要一个页表存储进程的数据。如果进程的内存上不封顶,那么页表有多少个条目合适呢? 进程分配多少空间合适呢? 如果页表大小为 1024 个条目,那么可以支持 1024*4K = 4M 空间。按照这个计算,如果进程需要 1G 空间,则需要 256K 个条目。我们预先为进程分配这 256K 个条目吗? 创建一个进程就划分这么多条目是不是成本太高了?
|
||||
|
||||
为了减少条目的创建,可以考虑进程内部用一个更大的页表(比如 4M),操作系统继续用 4K 的页表。这就形成了一个二级页表的结构,如下图所示:
|
||||
|
||||
|
||||
|
||||
这样 MMU 会先查询 1 级页表,再查询 2 级页表。在这个模型下,进程如果需要 1G 空间,也只需要 1024 个条目。比如 1 级页编号是 2, 那么对应 2 级页表中 [2* 1024, 3*1024-1] 的部分条目。而访问一个地址,需要同时给出一级页编号和二级页编号。整个地址,还可以用 64bit 组装,如下图所示:
|
||||
|
||||
|
||||
|
||||
MMU 根据 1 级编号找到 1 级页表条目,1 级页表条目中记录了对应 2 级页表的位置。然后 MMU 再查询 2 级页表,找到 Frame。最后通过地址偏移量和 Frame 编号计算最终的物理地址。这种设计是一个递归的过程,因此还可增加 3 级、4 级……每增加 1 级,对空间的利用都会提高——当然也会带来一定的开销。这对于大应用非常划算,比如需要 1T 空间,那么使用 2 级页表,页表的空间就节省得多了。而且,这种多级页表,顶级页表在进程中可以先只创建需要用到的部分,就这个例子而言,一开始只需要 3 个条目,从 256K 个条目到 3 个,这就大大减少了进程创建的成本。
|
||||
|
||||
总结
|
||||
|
||||
那么通过这节课的学习,你现在可以尝试来回答本节关联的面试题目:一个程序最多能使用多少内存?
|
||||
|
||||
【解析】 目前我们主要都是在用 64bit 的机器。因为 264 数字过于大,即便是虚拟内存都不需要这么大的空间。因此通常操作系统会允许进程使用非常大,但是不到 264 的地址空间。通常是几十到几百 EB(1EB = 106TB = 109GB)。
|
||||
|
||||
|
||||
|
||||
|
137
专栏/重学操作系统-完/25内存管理单元:什么情况下使用大内存分页?.md
Normal file
137
专栏/重学操作系统-完/25内存管理单元:什么情况下使用大内存分页?.md
Normal file
@@ -0,0 +1,137 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
25 内存管理单元: 什么情况下使用大内存分页?
|
||||
今天我们的学习目标是:了解如何通过内存,提升你的程序性能。这一讲我带来了一道和内存优化相关的面试题:什么情况下使用大内存分页?
|
||||
|
||||
这道题目属于一个实用技巧,可以作为你积累高并发处理技能的一个小小的组成部分。要理解和解决这个问题,我们还需要在上一讲的基础上,继续挖掘虚拟内存和内存管理单元更底层的工作原理,以及了解转置检测缓冲区(TLB)的作用。
|
||||
|
||||
那么接下来就请你带着这个优化问题,和我一起开始学习今天的内容。
|
||||
|
||||
内存管理单元
|
||||
|
||||
上一讲我们学习了虚拟地址到物理地址的转换过程。如下图所示:
|
||||
|
||||
|
||||
|
||||
你可以把虚拟地址看成由页号和偏移量组成,把物理地址看成由 Frame Number 和偏移量组成。在 CPU 中有一个完成虚拟地址到物理地址转换的小型设备,叫作内存管理单元(Memory Management Unit(MMU)。
|
||||
|
||||
在程序执行的时候,指令中的地址都是虚拟地址,虚拟地址会通过 MMU,MMU 会查询页表,计算出对应的 Frame Number,然后偏移量不变,组装成真实地址。然后 MMU 通过地址总线直接去访问内存。所以 MMU 承担了虚拟地址到物理地址的转换以及 CPU 对内存的操作这两件事情。
|
||||
|
||||
如下图所示,从结构上 MMU 在 CPU 内部,并且直接和地址总线连接。因此 MMU 承担了 CPU 和内存之间的代理。对操作系统而言,MMU 是一类设备,有多种型号,就好比显卡有很多型号一样。操作系统需要理解这些型号,会使用 MMU。
|
||||
|
||||
|
||||
|
||||
TLB 和 MMU 的性能问题
|
||||
|
||||
上面的过程,会产生一个问题:指令的执行速度非常快,而 MMU 还需要从内存中查询页表。最快的内存查询页需要从 CPU 的缓存中读取,假设缓存有 95% 的命中率,比如读取到 L2 缓存,那么每次操作也需要几个 CPU 周期。你可以回顾一下 CPU 的指令周期,如下图所示,有 fetch/decode/execute 和 store。
|
||||
|
||||
|
||||
|
||||
在 fetch、execute 和 store 这 3 个环节中都有可能发生内存操作,因此内存操作最好能在非常短的时间内完成,尤其是 Page Number 到 Frame Number 的映射,我们希望尽快可以完成,最好不到 0.2 个 CPU 周期,这样就不会因为地址换算而增加指令的 CPU 周期。
|
||||
|
||||
因此,在 MMU 中往往还有一个微型的设备,叫作转置检测缓冲区(Translation Lookaside Buffer,TLB)。
|
||||
|
||||
缓存的设计,通常是一张表,所以 TLB 也称作快表。TLB 中最主要的信息就是 Page Number到 Frame Number 的映射关系。
|
||||
|
||||
|
||||
|
||||
|
||||
Page Number
|
||||
Frame Number
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
如上表所示,最简单的表达就是一个二维表格,每一行是一个 Page Number 和一个 Frame Number。我们把这样的每一行称为一个缓存行(Cache Line),或者缓存条目(Entry)。
|
||||
|
||||
TLB 的作用就是根据输入的 Page Number,找到 Frame Number。TLB 是硬件实现的,因此速度很快。因为用户的局部程序,往往会反复利用相同的内存地址。比如说 for 循环会反复利用循环变量,因此哪怕是只有几十个缓存行的 TLB,也会有非常高的命中率。而且现在的多核 CPU,会为每个核心提供单独的 TLB。这样,相当于减少了 TLB 的冲突。比如酷睿 i7 CPU 当中,每个核心都有自己的 TLB,而且 TLB 还进行了类似 CPU 缓存的分级策略。在 i7 CPU 中,L1 级 TLB 一共 64 个,L2 级 TLB 一共 1024 个。通过这样的设计,绝大多数的页表查询就可以用 TLB 实现了。
|
||||
|
||||
TLB Miss 问题
|
||||
|
||||
如果 Page Number 在 TLB 总没有找到,我们称为TLB 失效(Miss)。这种情况,分成两种。
|
||||
|
||||
一种是软失效(Soft Miss),这种情况 Frame 还在内存中,只不过 TLB 缓存中没有。那么这个时候需要刷新 TLB 缓存。如果 TLB 缓存已经满了,就需要选择一个已经存在的缓存条目进行覆盖。具体选择哪个条目进行覆盖,我们称为缓存置换(缓存不够用了,需要置换)。缓存置换时,通常希望高频使用的数据保留,低频使用的数据被替换。比如常用的 LRU(Least Recently Used)算法就是基于这种考虑,每次置换最早使用的条目。
|
||||
|
||||
另一种情况是硬失效(Hard Miss),这种情况下对应的 Frame 没有在内存中,需要从磁盘加载。这种情况非常麻烦,首先操作系统要触发一个缺页中断(原有需要读取内存的线程被休眠),然后中断响应程序开始从磁盘读取对应的 Frame 到内存中,读取完成后,再次触发中断通知更新 TLB,并且唤醒被休眠的线程去排队。注意,线程不可能从休眠态不排队就进入执行态,因此 Hard Miss 是相对耗时的。
|
||||
|
||||
无论是软失效、还是硬失效,都会带来性能损失,这是我们不希望看到的。因此缓存的设计,就非常重要了。
|
||||
|
||||
TLB 缓存的设计
|
||||
|
||||
每个缓存行可以看作一个映射,TLB 的缓存行将 Page Number 映射到 Frame Number,通常我们设计这种基于缓存行(Cache Line)的缓存有 3 种映射方案:
|
||||
|
||||
|
||||
全相联映射(Fully Associative Mapping)
|
||||
直接映射(Direct Mapping)
|
||||
n 路组相联映射(n-way Set-Associative Mapping)
|
||||
|
||||
|
||||
所谓相联(Associative),讲的是缓存条目和缓存数据之间的映射范围。如果是全相联,那么一个数据,可能在任何条目。如果是组相联(Set-Associative),意味对于一个数据,只能在一部分缓存条目中出现(比如前 4 个条目)。
|
||||
|
||||
方案一:全相联映射(Fully Associative Mapping)
|
||||
|
||||
如果 TLB 用全相联映射实现,那么一个 Frame,可能在任何缓存行中。虽然名词有点复杂,但是通常新人设计缓存时,会本能地想到全相联。因为在给定的空间下,最容易想到的就是把缓存数据都放进一个数组里。
|
||||
|
||||
对于 TLB 而言,如果是全相联映射,给定一个具体的 Page Number,想要查找 Frame,需要遍历整个缓存。当然作为硬件实现的缓存,如果缓存条目少的情况下,可以并行查找所有行。这种行为在软件设计中是不存在的,软件设计通常需要循环遍历才能查找行,但是利用硬件电路可以实现这种并行查找到过程。可是如果条目过多,比如几百个上千个,硬件查询速度也会下降。所以,全相联映射,有着明显性能上的缺陷。我们不考虑采用。
|
||||
|
||||
方案二:直接映射(Direct Mapping)
|
||||
|
||||
对于水平更高一些的同学,马上会想到直接映射。直接映射类似一种哈希函数的形式,给定一个内存地址,可以通过类似于哈希函数计算的形式,去计算它在哪一个缓存条目。假设我们有 64 个条目,那么可以考虑这个计算方法:缓存行号 = Page Number % 64。
|
||||
|
||||
当然在这个方法中,假如实际的虚拟地址空间大小是 1G,页面大小是 4K,那么一共有 1G/4K = 262144 个页,平均每 262144⁄64 = 4096 个页共享一个条目。这样的共享行为是很正常的,本身缓存大小就不可能太大,之前我们讲过,性能越高的存储离 CPU 越近,成本越高,空间越小。
|
||||
|
||||
上面的设计解决了全相联映射的性能缺陷,那么缓存命中率如何呢?
|
||||
|
||||
一种最简单的思考就是能不能基于直接映射实现 LRU 缓存。仔细思考,其实是不可能实现的。因为当我们想要置换缓存的时候(新条目进来,需要寻找一个旧条目出去),会发现每次都只有唯一的选择,因为对于一个确定的虚拟地址,它所在的条目也是确定的。这导致直接映射不支持各种缓存置换算法,因此 TLB Miss 肯定会更高。
|
||||
|
||||
综上,我们既要解决直接映射的缓存命中率问题,又希望解决全相联映射的性能问题。而核心就是需要能够实现类似 LRU 的算法,让高频使用的缓存留下来——最基本的要求,就是一个被缓存的值,必须可以存在于多个位置——于是人们就发明了 n 路组相联映射。
|
||||
|
||||
方案三:n 路组相联映射(n-way Set-Associative Mapping)
|
||||
|
||||
组相联映射有点像哈希表的开放寻址法,但是又有些差异。组相联映射允许一个虚拟页号(Page Number)映射到固定数量的 n 个位置。举个例子,比如现在有 64 个条目,要查找地址 100 的位置,可以先用一个固定的方法计算,比如 100%64 = 36。这样计算出应该去条目 36 获取 Frame 数据。但是取出条目 36 看到条目 36 的 Page Number 不是 100,这个时候就顺延一个位置,去查找 37,38,39……如果是 4 路组相联,那么就只看 36,37,38,39,如果是8 路组相联,就只看 36-43 位置。
|
||||
|
||||
这样的方式,一个 Page Number 可以在 n 个位置出现,这样就解决了 LRU 算法的问题。每次新地址需要置换进来的时候,可以从 n 个位置中选择更新时间最早的条目置换出去。至于具体 n 设置为多少,需要实战的检验。而且缓存是一个模糊、基于概率的方案,本身对 n 的要求不是很大。比如:i7 CPU 的 L1 TLB 采用 4-way 64 条目的设计;L2 TLB 采用 8-way 1024 条目的设计。Intel 选择了这样的设计,背后有大量的数据支撑。这也是缓存设计的一个要点,在做缓存设计的时候,你一定要收集数据实际验证。
|
||||
|
||||
以上,我们解决了 TLB 的基本设计问题,最后选择采用 n 路组相联映射。 然后还遗留了一个问题,如果一个应用(进程)对内存的需求比较大,比如 1G,而默认分页 4K 比较小。 这种情况下会有 262144 个页。考虑到 1024 个条目的 TLB,那么 262144⁄1024 = 256,如果 256 个地址复用 1 个缓存,很容易冲突。这个问题如何解决呢?
|
||||
|
||||
大内存分页
|
||||
|
||||
解决上面的遗留问题,可以考虑采用大内存分页(Large Page 或 Huge Page)。 这里我们先复习一下上一讲学习的多级页表。 多层页面就是进程内部维护一张页表,比如说 4M 一个页表(一级),然后每个一级页表关联 1024 个二级页表。 这样会给 MMU 带来一定的负担,因为 MMU 需要先检查一级页表,再检查二级页表。 但是 MMU 仍然可以利用 TLB 进行加速。因为 TLB 是缓存,所有根据值查找结果的逻辑,都可以用 TLB。
|
||||
|
||||
但是这没有解决我们提出的页表太多的问题,最终这种多级页表的设计还是需要查询大小为 4K 的页(这里请你思考上面的例子,如果是 1G 空间有 262144 个页)。如果我们操作系统能够提供大小为 4M 的页,那么是不是就减少了 1024 倍的页数呢? ——这样就大大提高了 TLB 的查询性能。
|
||||
|
||||
因此 Linux 内核 2.6 版之后就开始提供大内存分页(HugeTable),默认是不开启的。如果你有应用需要使用大内存分页,可以考虑用下面的语句开启它:
|
||||
|
||||
sudo sysctl -w vm.nr_hugepages=2048
|
||||
|
||||
|
||||
sysctl其实就是修改一下配置项,上面我们允许应用使用最多 2048 个大内存页。上面语句执行后,你可以按照下方截图的方式去查看自己大内存页表使用的情况。
|
||||
|
||||
|
||||
|
||||
从上图中你可以看到我总共有 2048 个大内存页,每个大小是 2048KB。具体这个大小是不可以调整的,这个和机器用的 MMU 相关。
|
||||
|
||||
打开大内存分页后如果有应用需要使用,就会去申请大内存分页。比如 Java 应用可以用-XX:+UseLargePages开启使用大内存分页。 下图是我通过一个 Java 程序加上 UseLargePages 参数的结果。
|
||||
|
||||
|
||||
|
||||
注意:我的 Java 应用使用的分页数 = Total-Free+Rsvd = 2048-2032+180 = 196。Total 就是总共的分页数,Free 代表空闲的(包含 Rsvd,Reserved 预留的)。因此是上面的计算关系。
|
||||
|
||||
总结
|
||||
|
||||
那么通过这节课的学习,你现在可以尝试来回答本节关联的面试题目:什么情况下使用大内存分页?
|
||||
|
||||
【解析】 通常应用对内存需求较大时,可以考虑开启大内存分页。比如一个搜索引擎,需要大量在内存中的索引。有时候应用对内存的需求是隐性的。比如有的机器用来抗高并发访问,虽然平时对内存使用不高,但是当高并发到来时,应用对内存的需求自然就上去了。虽然每个并发请求需要的内存都不大, 但是总量上去了,需求总量也会随之提高高。这种情况下,你也可以考虑开启大内存分页。
|
||||
|
||||
|
||||
|
||||
|
143
专栏/重学操作系统-完/26缓存置换算法:LRU用什么数据结构实现更合理?.md
Normal file
143
专栏/重学操作系统-完/26缓存置换算法:LRU用什么数据结构实现更合理?.md
Normal file
@@ -0,0 +1,143 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
26 缓存置换算法: LRU 用什么数据结构实现更合理?
|
||||
这一讲给你带来的面试题目是:LRU 用什么数据结构实现更合理?
|
||||
|
||||
LRU(最近最少使用),是一种缓存置换算法。缓存是用来存储常用的数据,加速常用数据访问的数据结构。有软件实现,比如数据库的缓存;也有硬件实现,比如我们上一讲学的 TLB。缓存设计中有一个重要的环节:当缓存满了,新的缓存条目要写入时,哪个旧条目被置换出去呢?
|
||||
|
||||
这就需要用到缓存置换算法(Cache Replacement Algorithm)。缓存置换应用场景非常广,比如发生缺页中断后,操作系统需要将磁盘的页导入内存,那么已经在内存中的页就需要置换出去。CDN 服务器为了提高访问速度,需要决定哪些 Web 资源在内存中,哪些在磁盘上。CPU 缓存每次写入一个条目,也就相当于一个旧的条目被覆盖。数据库要决定哪些数据在内存中,应用开发要决定哪些数据在 Redis 中,而空间是有限的,这些都关联着缓存的置换。
|
||||
|
||||
今天我们就以 LRU 用什么数据结构实现更合理,这道缓存设计题目为引,为你讲解缓存设计中(包括置换算法在内)的一些通用的思考方法。
|
||||
|
||||
理想状态
|
||||
|
||||
设计缓存置换算法的期望是:每次将未来使用频率最低的数据置换出去。假设只要我们知道未来的所有指令,就可以计算出哪些内存地址在未来使用频率高,哪些内存地址在未来使用频率低。这样,我们总是可以开发出理论上最高效的缓存置换算法。
|
||||
|
||||
再复习下缓存的基本概念,在缓存中找到数据叫作一次命中(Hit),没有找到叫作穿透(Miss)。假设穿透的概率为 M,缓存的访问时间(通常叫作延迟)是 L,穿透的代价(访问到原始数据,比如 Redis 穿透,访问到 DB)也就是穿透后获取数据的平均时间是 T,那么 M*T+L 可以看作是接近缓存的平均响应时间。L 通常是不变的,这个和我们使用了什么缓存相关。这样,如果我们知道未来访问数据的顺序,就可以把 M 降到最低,让缓存平均响应时间降到最低。
|
||||
|
||||
当然这只是美好的愿望,在实际工作中我们还不可能预知未来。
|
||||
|
||||
随机/FIFO/FILO
|
||||
|
||||
接下来我要和你讨论的 3 种策略,是对理想状态的一种悲观表达,或者说不好的设计。
|
||||
|
||||
比如说随机置换,一个新条目被写入,随机置换出去一个旧条目。这种设计,具有非常朴素的公平,但是性能会很差(穿透概率高),因为可能置换出去未来非常需要的数据。
|
||||
|
||||
再比如先进先出(First In First Out)。设计得不好的电商首页,每次把离现在时间最久的产品下线,让新产品有机会展示,而忽略销量、热度、好评等因素。这也是一种朴素的公平,但是和我们设计缓存算法的初衷——预估未来使用频率更高的数据保留在缓存中,相去甚远。所以,FIFO 的结构也是一种悲观的设计。
|
||||
|
||||
FIFO 的结构使用一个链表就能实现,如下图所示:
|
||||
|
||||
|
||||
|
||||
为了方便你理解本讲后面的内容,我在这里先做一个知识铺垫供你参考。上图中,新元素从链表头部插入,旧元素从链表尾部离开。 这样就构成了一个队列(Queue),队列是一个经典的 FIFO 模型。
|
||||
|
||||
还有一种策略是先进后出(First In Last Out)。但是这种策略和 FIFO、随机一样,没有太强的实际意义。因为先进来的元素、后进来的元素,还是随机的某个元素,和我们期望的未来使用频率,没有任何本质联系。
|
||||
|
||||
同样 FILO 的策略也可以用一个链表实现,如下图所示:
|
||||
|
||||
|
||||
|
||||
新元素从链表头部插入链表,旧元素从链表头部离开链表,就构成了一个栈(Stack),栈是一种天然的 FILO 数据结构。这里仅供参考了,我们暂时还不会用到这个方法。
|
||||
|
||||
当然我们不可能知道未来,但是可以考虑基于历史推测未来。经过前面的一番分析,接下来我们开始讨论一些更有价值的置换策略。
|
||||
|
||||
最近未使用(NRU)
|
||||
|
||||
一种非常简单、有效的缓存实现就是优先把最近没有使用的数据置换出去(Not Recently Used)。从概率上说,最近没有使用的数据,未来使用的概率会比最近经常使用的数据低。缓存设计本身也是基于概率的,一种方案有没有价值必须经过实践验证——在内存缺页中断后,如果采用 NRU 置换页面,可以提高后续使用内存的命中率,这是实践得到的结论。
|
||||
|
||||
而且 NRU 实现起来比较简单,下图是我们在“24 讲”中提到的页表条目设计。
|
||||
|
||||
|
||||
|
||||
在页表中有一个访问位,代表页表有被读取过。还有一个脏位,代表页表被写入过。无论是读还是写,我们都可以认为是访问过。 为了提升效率,一旦页表被使用,可以用硬件将读位置 1,然后再设置一个定时器,比如 100ms 后,再将读位清 0。当有内存写入时,就将写位置 1。过一段时间将有内存写入的页回写到磁盘时,再将写位清 0。这样读写位在读写后都会置为 1,过段时间,也都会回到 0。
|
||||
|
||||
上面这种方式,就构成了一个最基本的 NRU 算法。每次置换的时候,操作系统尽量选择读、写位都是 0 的页面。而一个页面如果在内存中停留太久,没有新的读写,读写位会回到 0,就可能会被置换。
|
||||
|
||||
这里多说一句,NRU 本身还可以和其他方法结合起来工作,比如我们可以利用读、写位的设计去改进 FIFO 算法。
|
||||
|
||||
每次 FIFO 从队列尾部找到一个条目要置换出去的时候,就检查一下这个条目的读位。如果读位是 0,就删除这个条目。如果读位中有 1,就把这个条目从队列尾部移动到队列的头部,并且把读位清 0,相当于多给这个条目一次机会,因此也被称为第二次机会算法。多给一次机会,就相当于发生访问的页面更容易存活。而且,这样的算法利用天然的数据结构优势(队列),保证了 NRU 的同时,节省了去扫描整个缓存寻找读写位是 0 的条目的时间。
|
||||
|
||||
第二次机会算法还有一个更巧妙的实现,就是利用循环链表。这个实现可以帮助我们节省元素从链表尾部移动到头部的开销。
|
||||
|
||||
|
||||
|
||||
如上图所示,我们可以将从尾部移动条目到头部的这个操作简化为头指针指向下一个节点。每次移动链表尾部元素到头部,只需要操作头指针指向下一个元素即可。这个方法非常巧妙,而且容易实现,你可以尝试在自己系统的缓存设计中尝试使用它。
|
||||
|
||||
以上,是我们学习的第一个比较有价值的缓存置换算法。基本可用,能够提高命中率。缺点是只考虑了最近用没用过的情况,没有充分考虑综合的访问情况。优点是简单有效,性能好。缺点是考虑不周,对缓存的命中率提升有限。但是因为简单,容易实现,NRU 还是成了一个被广泛使用的算法。
|
||||
|
||||
最近使用最少(LRU)
|
||||
|
||||
一种比 NRU 考虑更周密,实现成本更高的算法是最近最少使用(Least Recently Used, LRU)算法,它会置换最久没有使用的数据。和 NRU 相比,LRU 会考虑一个时间范围内的数据,对数据的参考范围更大。LRU 认为,最近一段时间最少使用到的数据应该被淘汰,把空间让给最近频繁使用的数据。这样的设计,即便数据都被使用过,还是会根据使用频次多少进行淘汰。比如:CPU 缓存利用 LUR 算法将空间留给频繁使用的内存数据,淘汰使用频率较低的内存数据。
|
||||
|
||||
常见实现方案
|
||||
|
||||
LRU 的一种常见实现是链表,如下图所示:
|
||||
|
||||
|
||||
|
||||
用双向链表维护缓存条目。如果链表中某个缓存条目被使用到,那么就将这个条目重新移动到表头。如果要置换缓存条目出去,就直接从双线链表尾部删除一个条目。
|
||||
|
||||
通常 LRU 缓存还要提供查询能力,这里我们可以考虑用类似 Java 中 LinkedHashMap 的数据结构,同时具备双向链表和根据 Key 查找值的能力。
|
||||
|
||||
以上是常见的实现方案,但是这种方案在缓存访问量非常大的情况下,需要同时维护一个链表和一个哈希表,因此开销较高。
|
||||
|
||||
举一个高性能场景的例子,比如页面置换算法。 如果你需要维护一个很大的链表来存储所有页,然后经常要删除大量的页面(置换缓存),并把大量的页面移动到链表头部。这对于页面置换这种高性能场景来说,是不可以接受的。
|
||||
|
||||
另外一个需要 LRU 高性能的场景是 CPU 的缓存,CPU 的多路组相联设计,比如 8-way 设计,需要在 8 个地址中快速找到最久未使用的数据,不可能再去内存中建立一个链表来实现。
|
||||
|
||||
正因为有这么多困难,才需要不断地优化迭代,让缓存设计成为一门艺术。接下来我选取了内存置换算法中数学模拟 LRU 的算法,分享给你。
|
||||
|
||||
如何描述最近使用次数?
|
||||
|
||||
设计 LRU 缓存第一个困难是描述最近使用次数。 因为“最近”是一个模糊概念,没有具体指出是多长时间?按照 CPU 周期计算还是按照时间计算?还是用其他模糊的概念替代?
|
||||
|
||||
比如说页面置换算法。在实际的设计中,可以考虑把页表的读位利用起来。做一个定时器,每隔一定的 ms 数,就把读位累加到一个计数器中。相当于在每个页表条目上再增加一个累计值。
|
||||
|
||||
例如:现在某个页表条目的累计值是 0, 接下来在多次计数中看到的读位是:1,0,0,1,1,那么累计值就会变成 3。这代表在某段时间内(5 个计数器 Tick 中)有 3 次访问操作。
|
||||
|
||||
通过这种方法,就解决了描述使用次数的问题。如果单纯基于使用次数最少判断置换,我们称为最少使用(Least Frequently Used,,LFU)算法。LFU 的劣势在于它不会忘记数据,累计值不会减少。比如如果有内存数据过去常常被用到,但是现在已经有很长一段时间没有被用到了,在这种情况下它并不会置换出去。那么我们该如何描述“最近”呢?
|
||||
|
||||
有一个很不错的策略就是利用一个叫作“老化”(Aging)的算法。比起传统的累加计数的方式,Aging 算法的累加不太一样。
|
||||
|
||||
比如用 8 位来描述累计数(A),那么每次当读位的值(R)到来的时候,我们都考虑将 A 的值右移,然后将 R 放到 A 的最高位。
|
||||
|
||||
例如 A 目前的值是00000000,在接下来的 5 个 Tick 中 R 来临的序列是11100,那么 A 的值变更顺序为:
|
||||
|
||||
|
||||
10000000
|
||||
11000000
|
||||
11100000
|
||||
01110000
|
||||
00111000
|
||||
|
||||
|
||||
你可以看到随着 Aging 算法的执行,有访问操作的时候 A 的值上升,没有访问操作的时候,A的值逐渐减少。如果一直没有访问操作,A 的值会回到 0。
|
||||
|
||||
这样的方式就巧妙地用数学描述了“最近”。然后操作系统每次页面置换的时候,都从 A 值最小的集合中取出一个页面放入磁盘。这个算法是对 LRU 的一种模拟,也被称作 LFUDA(动态老化最少使用,其中 D 是 Dynamic,,A 是 Aging)。
|
||||
|
||||
而计算 Aging(累计值)的过程,可以由硬件实现,这样就最大程度提升了性能。
|
||||
|
||||
相比写入操作,查询是耗时相对较少的。这是因为有 CPU 缓存的存在,我们通常不用直接去内存中查找数据,而是在缓存中进行。对于发生缺页中断的情况,并不需要追求绝对的精确,可以在部分页中找到一个相对累计值较小的页面进行置换。不过即便是模拟的 LRU 算法,也不是硬件直接支持的,总有一部分需要软件实现,因此还是有较多的时间开销。
|
||||
|
||||
是否采用 LRU,一方面要看你所在场景的性能要求,有没有足够的优化措施(比如硬件提速);另一方面,就要看最终的结果是否能够达到期望的命中率和期望的使用延迟了。
|
||||
|
||||
总结
|
||||
|
||||
本讲我们讨论的频次较高、频次较低,是基于历史的。 历史在未来并不一定重演。比如读取一个大型文件,无论如何操作都很难建立一个有效的缓存。甚至有的时候,最近使用频次最低的数据被缓存,使用频次最高的数据被置换,效率会更高。比如说有的数据库设计同时支持 LRU 缓存和 MRU( Most Recently Used)缓存。MRU 是 LRU 的对立面,这看似茅盾,但其实是为了解决不同情况下的需求。
|
||||
|
||||
这并不是说缓存设计无迹可寻,而是经过思考和预判,还得以事实的命中率去衡量缓存置换算法是否合理。
|
||||
|
||||
那么通过这节课的学习,你现在可以尝试来回答本节关联的面试题目:LRU 用什么数据结构实现更合理?
|
||||
|
||||
【解析】 最原始的方式是用数组,数组的每一项中有数据最近的使用频次。数据的使用频次可以用计时器计算。每次置换的时候查询整个数组实现。
|
||||
|
||||
另一种更好的做法是利用双向链表实现。将使用到的数据移动到链表头部,每次置换时从链表尾部拿走数据。链表头部是最近使用的,链表尾部是最近没有被使用到的数据。
|
||||
|
||||
但是在应对实际的场景的时候,有时候不允许我们建立专门用于维护缓存的数据结构(内存大小限制、CPU 使用限制等),往往需要模拟 LRU。比如在内存置换场景有用“老化”技术模拟 LRU 计算的方式。
|
||||
|
||||
|
||||
|
||||
|
195
专栏/重学操作系统-完/27内存回收上篇:如何解决内存的循环引用问题?.md
Normal file
195
专栏/重学操作系统-完/27内存回收上篇:如何解决内存的循环引用问题?.md
Normal file
@@ -0,0 +1,195 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
27 内存回收上篇:如何解决内存的循环引用问题?
|
||||
内存泄漏一直是很多大型系统故障的根源,也是一个面试热点。那么在编程语言层面已经提供了内存回收机制,为什么还会产生内存泄漏呢?
|
||||
|
||||
这是因为应用的内存管理一直处于一个和应用程序执行并发的状态,如果应用程序申请内存的速度,超过内存回收的速度,内存就会被用满。当内存用满,操作系统就开始需要频繁地切换页面,进行频繁地磁盘读写。所以我们观察到的系统性能下降,往往是一种突然的崩溃,因为一旦内存被占满,系统性能就开始雪崩式下降。
|
||||
|
||||
特别是有时候程序员不懂内存回收的原理,错误地使用内存回收器,导致部分对象没有被回收。而在高并发场景下,每次并发都产生一点不能回收的内存,不用太长时间内存就满了,这就是泄漏通常的成因。
|
||||
|
||||
这一块知识点关联着很多常见的面试题,比如。
|
||||
|
||||
|
||||
这一讲关联的题目:如何解决循环引用问题?
|
||||
下节课关联的题目:三色标记-清除算法的工作原理?生代算法等。
|
||||
还有一些题目会考察你对内存回收器整体的理解,比如如何在吞吐量、足迹和暂停时间之间选择?
|
||||
|
||||
|
||||
接下来,我会用 27 和 28 两讲和你探讨内存回收技术,把这些问题一网打尽。
|
||||
|
||||
什么是 GC
|
||||
|
||||
通常意义上我们说的垃圾回收器(Garbage Collector,GC),和多数同学的理解会有出入。你可能认为 GC 是做内存回收用的模块,而事实上程序语言提供的 GC 往往是应用的实际内存管理者。刚刚入门咱们就遇到了一个容易出现理解偏差的问题,所以 GC 是值得花时间细学的。
|
||||
|
||||
|
||||
|
||||
如上图所示,一方面 GC 要承接操作系统虚拟内存的架构,另一方面 GC 还要为应用提供内存管理。GC 有一个含义,就是 Garbage Collection 内存回收的具体动作。无论是名词的回收器,还是动词的回收行为,在下文中我都称作 GC。
|
||||
|
||||
下面我们具体来看一下 GC 都需要承担哪些“工作”,这里我总结为以下 4 种。
|
||||
|
||||
|
||||
GC 要和操作系统进行交互,负责申请内存,并把不用的内存还给操作系统(释放内存)。
|
||||
应用会向 GC 申请内存。
|
||||
GC 要承担我们通常意义上说的垃圾回收能力,标记不用的对象,并回收他们。
|
||||
GC 还需要针对应用特性进行动态的优化。
|
||||
|
||||
|
||||
所以现在程序语言实现的 GC 模块通常是实际负责应用内存管理的模块。在程序语言实现 GC 的时候,会关注下面这几个指标。
|
||||
|
||||
|
||||
吞吐量(Throughput):执行程序(不包括 GC 执行的时间)和总是间的占比。注意这个吞吐量和通常意义上应用去处理作业的吞吐量是不一样的,这是从 GC 的角度去看应用。只要不在 GC,就认为是吞吐量的一部分。
|
||||
足迹(FootPrint): 一个程序使用了多少硬件的资源,也称作程序在硬件上的足迹。GC 里面说的足迹,通常就是应用对内存的占用情况。比如说应用运行需要 2G 内存,但是好的 GC 算法能够帮助我们减少 500MB 的内存使用,满足足迹这个指标。
|
||||
暂停时间(Pause Time): GC 执行的时候,通常需要停下应用(避免同步问题),这称为 Stop The World,或者暂停。不同应用对某次内存回收可以暂停的时间需求是不同的,比如说一个游戏应用,暂停了几毫秒用户都可能有很大意见;而看网页的用户,稍微慢了几毫秒是没有感觉的。
|
||||
|
||||
|
||||
GC 目标的思考
|
||||
|
||||
如果单纯从让 GC 尽快把工作做完的角度来讲,其实是提升吞吐量。比如利用好多核优势就是一种最直观的方法。
|
||||
|
||||
因为涉及并行计算,我这里给你讲讲并行计算领域非常重要的阿姆达定律,这个定律用来衡量并行计算对原有算法的改进,公式如下:
|
||||
|
||||
S = 1 / (1- P)
|
||||
|
||||
你现在看到的是一个简化版的阿姆达定律,P 是任务中可以并发执行部分的占比,S 是并行带来的理论提速倍数的极限。比如说 P 是 0.9,代入公式可得:
|
||||
|
||||
S = 1 / (1 - 0.9) = 10
|
||||
|
||||
上面表达式代表着有 90% 的任务可以并行,只有 10% 的任务不能够并行。假设我们拥有无限多的 CPU 去分担 90% 可以并行的任务,其实就相当于并行的任务可以在非常短的时间内完成。但是还有 10% 的任务不能并行,因此理论极限是 1⁄0.1=10 倍。
|
||||
|
||||
通常我们设计 GC,都希望它能够支持并行处理任务。因为 GC 本身也有着繁重的工作量,需要扫描所有的对象,对内存进行标记清除和整理等。
|
||||
|
||||
经过上述分析,那么我们在设计算法的时候是不是应该尽量做到高并发呢?
|
||||
|
||||
很可惜并不是这样。如果算法支持的并发度非常高,那么和单线程算法相比,它也会带来更多的其他开销。比如任务拆分的开销、解决同步问题的开销,还有就是空间开销,GC 领域空间开销通常称为 FootPrint。理想情况下当然是核越多越好,但是如果考虑计算本身的成本,就需要找到折中的方案。
|
||||
|
||||
还有一个问题是,GC 往往不能拥有太长的暂停时间(Pause Time),因为 GC 和应用是并发的执行。如果 GC 导致应用暂停(Stop The World,STL)太久,那么对有的应用来说是灾难性的。 比如说你用鼠标的时候,如果突然卡了你会很抓狂。如果一个应用提供给百万级的用户用,假设这个应用帮每个用户每天节省了 1s 的等待时间,那么按照乔布斯的说法每天就为用户节省了 11 天的时间,每年是 11 年——5 年就相当于拯救了一条生命。
|
||||
|
||||
如果暂停时间只允许很短,那么 GC 和应用的交替就需要非常频繁。这对 GC 算法要求就会上升,因为每次用户程序执行后,会产生新的变化,甚至会对已有的 GC 结果产生影响。后面我们在讨论标记-清除算法的时候,你会感受到这种情况。
|
||||
|
||||
所以说,吞吐量高,不代表暂停时间少,也不代表空间使用(FootPrint)小。 同样的,使用空间小的 GC 算法,吞吐量反而也会下降。正因为三者之间存在类似相同成本代价下不可兼得的关系,往往编程语言会提供参数让你选择根据自己的应用特性决定 GC 行为。
|
||||
|
||||
引用计数算法(Reference Counter)
|
||||
|
||||
接下来我们说说,具体怎么去实现 GC。实现 GC 最简单的方案叫作引用计数,下图中节点的引用计数是 2,代表有两个节点都引用了它。
|
||||
|
||||
|
||||
|
||||
如果一个节点的引用计数是 0,就意味着没有任何一个节点引用它——此时,理论上这个节点应该被回收。GC 不断扫描引用计数为 0 的节点进行回收,就构成了最简单的一个内存回收算法。
|
||||
|
||||
但是,这个算法可能会出现下图中循环引用的问题(我们写程序的过程中经常会遇到这样的引用关系)。下图中三个节点,因为循环引用,引用计数都是 1。
|
||||
|
||||
|
||||
|
||||
引用计数是 1,因此就算这 3 个对象不会再使用了,GC 不会回收它们。
|
||||
|
||||
另一个考虑是在多线程环境下引用计数的算法一旦算错 1 次(比如因为没有处理好竞争条件),那么就无法再纠正了。而且处理竞争条件本身也比较耗费性能。
|
||||
|
||||
还有就是引用计数法回收内存会产生碎片,当然碎片不是只有引用计数法才有的问题,所有的 GC 都需要面对碎片。下图中内存回收的碎片可以通过整理的方式,清理出更多空间出来。关于内存空间的碎片,下一讲会有专门的一个小节讨论。
|
||||
|
||||
|
||||
|
||||
综上,引用计数法出错概率大,比如我们编程时会有对象的循环引用;另一方面,引用计数法容错能力差,一旦计算错了,就会导致内存永久无法被回收,因此我们需要更好的方式。
|
||||
|
||||
Root Tracing 算法
|
||||
|
||||
下面我再给你介绍一种更好的方式—— Root Tracing 算法。这是一类算法,后面我们会讲解的标记-清除算法和 3 色标记-清除算法都属于这一类。
|
||||
|
||||
Root Tracing 的原理是:从引用路径上,如果一个对象的引用链中包括一个根对象(Root Object),那么这个对象就是活动的。根对象是所有引用关系的源头。比如用户在栈中创建的对象指针;程序启动之初导入数据区的全局对象等。在 Java 中根对象就包括在栈上创建指向堆的对象;JVM 的一些元数据,包括 Method Area 中的对象等。
|
||||
|
||||
|
||||
|
||||
在 Root Tracing 工作过程中,如果一个对象和根对象间有连通路径,也就是从根节点开始遍历可以找到这个对象,代表有对象可以引用到这个对象,那么这个节点就不需要被回收。所以算法的本质还是引用,只不过判断条件从引用计数变成了有根对象的引用链。
|
||||
|
||||
如果一个对象从根对象不可达,那么这个对象就应该被回收,即便这个对象存在循环引用。可以看到,上图中红色的 3 个对象循环引用,并且到根集合没有引用链,因此需要被回收。这样就解决了循环引用的问题。
|
||||
|
||||
Root Tracing 的容错性很好,GC 通过不断地执行 Root Tracing 算法找到需要回收的元素。如果在这个过程中,有一些本来应该回收的元素没有被计算出(比如并发原因),也不会导致这些对象永久无法回收。因为在下次执行 Root Tracing 的时候,GC 就会通过执行 Root Tracing 算法找到这些元素。不像引用计数法,一旦算错就很难恢复。
|
||||
|
||||
标记-清除(Mark Sweep)算法
|
||||
|
||||
下面我为你具体介绍一种 Root Tracing 的算法, 就是标记清除-算法。标记-清除算法中,用白色代表一种不确定的状态:可能被回收。 黑色代表一种确定的状态:不会被回收。算法的实现,就是为所有的对象染色。算法执行结束后,所有是白色的对象就需要被回收。
|
||||
|
||||
算法实现过程中,假设有两个全局变量是已知的:
|
||||
|
||||
|
||||
heapSet 中拥有所有对象
|
||||
rootSet 中拥有所有 Root Object
|
||||
|
||||
|
||||
算法执行的第一步,就是将所有的对象染成白色,代码如下:
|
||||
|
||||
for obj in heapSet {
|
||||
|
||||
obj.color = white
|
||||
|
||||
}
|
||||
|
||||
|
||||
接下来我们定义一个标记函数,它会递归地将一个对象的所有子对象染成黑色,代码如下:
|
||||
|
||||
func mark(obj) {
|
||||
|
||||
if obj.color == white {
|
||||
|
||||
obj.color = black
|
||||
|
||||
for v in references(obj) {
|
||||
|
||||
mark(v)
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
补充知识
|
||||
|
||||
上面的 mark 函数对 obj 进行了深度优先搜索。深度优先搜索,就是自然的递归序。随着递归函数执行,遇到子元素就遍历子元素,就构成了天然的深度优先搜索。还有一个相对的概念是广度优先搜索(Breadth First Serach),如果你不知道深度优先搜索和广度优先搜索,可以看下我下面的图例。
|
||||
|
||||
|
||||
|
||||
上图中,深度优先搜索优先遍历完整的子树(递归),广度优先搜索优先遍历所有的子节点(逐层)。
|
||||
|
||||
然后我们从所有的 Root Object 开始执行 mark 函数:
|
||||
|
||||
for root in rootSet {
|
||||
|
||||
mark(root)
|
||||
|
||||
}
|
||||
|
||||
|
||||
以上程序执行结束后,所有和 Root Object 连通的对象都已经被染成了黑色。然后我们遍历整个 heapSet 找到白色的对象进行回收,这一步开始是清除(Sweep)阶段,以上是标记(Mark)阶段。
|
||||
|
||||
for obj in heapSet {
|
||||
|
||||
if obj.color == white {
|
||||
|
||||
free(obj)
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
以上算法就是一个简单的标记-清除算法。相比引用计数,这个算法不需要维护状态。算法执行开始所有节点都被标记了一遍。结束的时候,算法找到的垃圾就被清除了。 算法有两个阶段,标记阶段(Mark),还有清除阶段(Sweep),因此被称为标记-清除算法。
|
||||
|
||||
这里请你思考:如果上面的 GC 程序在某个时刻暂停了下来,然后开始执行用户程序。如果用户程序删除了对某个已经标记为黑色对象的所有引用,用户程序没办法通知 GC 程序。这个节点就会变成浮动垃圾(Floating Garbage),需要等待下一个 GC 程序执行。
|
||||
|
||||
|
||||
|
||||
假设用户程序和 GC 交替执行,用户程序不断进行修改(Mutation),而 GC 不断执行标记-清除算法。那么这中间会产生大量浮动垃圾影响 GC 的效果。
|
||||
|
||||
另一方面,考虑到 GC 是一个非常消耗性能程序,在某些情况下,我们希望 GC 能够增量回收。 比如说,用户仅仅是高频删除了一部分对象,那么是否可以考虑设计不需要从整个 Root 集合进行遍历,而是增量的只处理最近这一批变更的算法呢?答案是可以的,我们平时可以多执行增量 GC,偶尔执行一次全量 GC。具体增量的方式会在下一讲为你讲解。
|
||||
|
||||
总结
|
||||
|
||||
讨论到这里,相信你已经对 GC 有了一个大致的认识,但是具体到不同的场景如何设计 GC 算法,比如上面提到的标记-清除算法的缺陷,该如何去弥补呢? 还有在高并发场景应该如何选择 GC 算法呢?当你拿到一个 GC 工具,又应该如何去设置参数,调整计算资源和存储资源比例呢?这些问题, 你可以先在自己脑海中思考,然后我会在下一讲为你讲解更好的方案。
|
||||
|
||||
|
||||
|
||||
|
155
专栏/重学操作系统-完/28(1)加餐练习题详解(五).md
Normal file
155
专栏/重学操作系统-完/28(1)加餐练习题详解(五).md
Normal file
@@ -0,0 +1,155 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
28 (1)加餐 练习题详解(五)
|
||||
今天我会带你把《模块五:内存管理》中涉及的课后练习题,逐一讲解,并给出每个课时练习题的解题思路和答案。
|
||||
|
||||
练习题详解
|
||||
|
||||
24 | 虚拟内存 :一个程序最多能使用多少内存?
|
||||
|
||||
【问题】可不可以利用哈希表直接将页编号映射到 Frame 编号?
|
||||
|
||||
【解析】按照普通页表的设计,如果页大小是 4K,1G 空间内存需要 262144 个页表条目,如果每个条目用 4 个字节来存储,就需要 1M 的空间。那么创建 1T 的虚拟内存,就需要 1G 的空间。这意味着操作系统需要在启动时,就把这块需要的内存空间预留出来。
|
||||
|
||||
正因为我们设计的虚拟内存往往大于实际的内存,因此在历史上出现过各种各样节省页表空间的方案,其中就有用 HashTable 存储页表的设计。HashTable 是一种将键(Key)映射到值(Value)的数据结构。在页表的例子中,键是页编号,值是 Frame 编号。 你可以把这个 HashTable 看作存储了很多 键值对的数据结构。
|
||||
|
||||
为了方便你理解下面的内容,我绘制了一张图。下图使用了一个有 1024 个条目的 HashTable。当查找页面 50000 的时候,先通过哈希函数 h 计算出 50000 对应的 HashTable 条目是 24。HashTable 的每个条目都是一个链表,链表的每个节点是一个 PageId 和 FrameId 的组合。接下来,算法会遍历条目 24 上的链表,然后找到 Page = 50000 的节点。取出 Frame 编号为 1232。
|
||||
|
||||
|
||||
|
||||
通常虚拟内存会有非常多的页,但是只有少数的页会被使用到。这种情况下,用传统的页表,会导致过多的空间被预分配。而基于 HashTable 的设计则不同,可以先分配少量的项,比如在上图中,先只分配了 1024 个项。每次查找一个页表编号发现不存在的情况,再去对应位置的链表中添加一个具体的键-值对。 这样就大大节省了内存。
|
||||
|
||||
当然节省空间也是有代价的,这会直接导致性能下降,因为比起传统页表我们可以直接通过页的编号知道页表条目,基于 HashTable 的做法需要先进行一次 Hash 函数的计算,然后再遍历一次链表。 最后,HashTable 的时间复杂度可以看作 O(k),k 为 HashTable 表中总共的 数量除以哈希表的条目数。当 k 较小的时候 HashTable 的时间复杂度趋向于 O(1)。
|
||||
|
||||
25 | 内存管理单元:什么情况下使用大内存分页?
|
||||
|
||||
【问题】Java 和 Go 默认需不需要开启大内存分页?
|
||||
|
||||
【解析】在回答什么情况下使用前,我们先说说这两个语言对大内存分页的支持。
|
||||
|
||||
当然,两门语言能够使用大内存分页的前提条件,是通过“25 讲”中演示的方式,开启了操作系统的大内存分页。满足这个条件后,我们再来说说两门语言还需要做哪些配置。
|
||||
|
||||
Go 语言
|
||||
|
||||
Go 是一门编译执行的语言。在 Go 编译器的前端,源代码被转化为 AST;在 Go 编译器的后端,AST 经过若干优化步骤,转化为目标机器代码。因此 Go 的内存分配程序基本上可以直接和操作系统的 API 对应。因为 Go 没有虚拟机。
|
||||
|
||||
而且 Go 提供了一个底层的库 syscall,直接支持上百个系统调用。 具体请参考Go 的官方文档。其中的 syscall.madvise 系统调用,可以直接提示操作系统某个内存区间的程序是否使用大内存分页技术加速 TLB 的访问。具体可以参考 Linux 中madise 的文档,这个工具的作用主要是提示操作系统如何使用某个区域的内存,开启大内存分页是它之中的一个选项。
|
||||
|
||||
下面的程序通过 malloc 分配内存,然后用 madvise 提示操作系统使用大内存分页的示例:
|
||||
|
||||
#include <sys/mman.h>
|
||||
|
||||
size_t size = 256*1024*1024;
|
||||
|
||||
char* mymemory = malloc(size);
|
||||
|
||||
madvise(mymemory, size, MADV_HUGEPAGE);
|
||||
|
||||
|
||||
如果放到 Go 语言,那么需要用的是runtime.sysAlloc和syscall.Madvise函数。
|
||||
|
||||
Java 语言
|
||||
|
||||
JVM 是一个虚拟机,应用了Just-In-Time 在虚拟指令执行的过程中,将虚拟指令转换为机器码执行。 JVM 自己有一套完整的动态内存管理方案,而且提供了很多内存管理工具可选。在使用 JVM 时,虽然 Java 提供了 UnSafe 类帮助我们执行底层操作,但是通常情况下我们不会使用UnSafe 类。一方面 UnSafe 类功能不全,另一方面看名字就知道它过于危险。
|
||||
|
||||
Java 语言在“25 讲”中提到过有一个虚拟机参数:XX:+UseLargePages,开启这个参数,JVM 会开始尝试使用大内存分页。
|
||||
|
||||
那么到底该不该用大内存分页?
|
||||
|
||||
首先可以分析下你应用的特性,看看有没有大内存分页的需求。通常 OS 是 4K,思考下你有没有需要反复用到大内存分页的场景。
|
||||
|
||||
另外你可以使用perf指令衡量你系统的一些性能指标,其中就包括iTLB-load-miss可以用来衡量 TLB Miss。 如果发现自己系统的 TLB Miss 较高,那么可以深入分析是否需要开启大内存分页。
|
||||
|
||||
26 | 缓存置换算法: LRU 用什么数据结构实现更合理?
|
||||
|
||||
【问题】在 TLB 多路组相联缓存设计中(比如 8-way),如何实现 LRU 缓存?
|
||||
|
||||
【解析】TLB 是 CPU 的一个“零件”,在 TLB 的设计当中不可能再去内存中创建数据结构。因此在 8 路组相联缓存设计中,我们每次只需要从 8 个缓存条目中选择 Least Recently Used 缓存。
|
||||
|
||||
增加累计值
|
||||
|
||||
先说一种方法, 比如用硬件同时比较 8 个缓存中记录的缓存使用次数。这种方案需要做到 2 点:
|
||||
|
||||
|
||||
缓存条目中需要额外的空间记录条目的使用次数(累计位)。类似我们在页表设计中讨论的基于计时器的读位操作——每过一段时间就自动将读位累计到一个累计位上。
|
||||
硬件能够实现一个快速查询最小值的算法。
|
||||
|
||||
|
||||
第 1 种方法会产生额外的空间开销,还需要定时器配合,成本较高。 注意缓存是很贵的,对于缓存空间利用自然能省则省。而第 2 种方法也需要额外的硬件设计。那么,有没有更好的方案呢?
|
||||
|
||||
1bit 模拟 LRU
|
||||
|
||||
一个更好的方案就是模拟 LRU,我们可以考虑继续采用上面的方式,但是每个缓存条目只拿出一个 LRU 位(bit)来描述缓存近期有没有被使用过。 缓存置换时只是查找 LRU 位等于 0 的条目置换。
|
||||
|
||||
还有一个基于这种设计更好的方案,可以考虑在所有 LRU 位都被置 1 的时候,清除 8 个条目中的 LRU 位(置零),这样可以节省一个计时器。 相当于发生内存操作,LRU 位置 1;8 个位置都被使用,LRU 都置 0。
|
||||
|
||||
搜索树模拟 LRU
|
||||
|
||||
最后我再介绍一个巧妙的方法——用搜索树模拟 LRU。
|
||||
|
||||
对于一个 8 路组相联缓存,这个方法需要 8-1 = 7bit 去构造一个树。如下图所示:
|
||||
|
||||
|
||||
|
||||
8 个缓存条目用 7 个节点控制,每个节点是 1 位。0 代表节点指向左边,1 代表节点指向右边。
|
||||
|
||||
初始化的时候,所有节点都指向左边,如下图所示:
|
||||
|
||||
|
||||
|
||||
接下来每次写入,会从根节点开始寻找,顺着箭头方向(0 向左,1 向右),找到下一个更新方向。比如现在图中下一个要更新的位置是 0。更新完成后,所有路径上的节点箭头都会反转,也就是 0 变成 1,1 变成 0。
|
||||
|
||||
|
||||
|
||||
上图是read a后的结果,之前路径上所有的箭头都被反转,现在看到下一个位置是 4,我用橘黄色进行了标记。
|
||||
|
||||
|
||||
|
||||
上图是发生操作read b之后的结果,现在橘黄色可以更新的位置是 2。
|
||||
|
||||
|
||||
|
||||
上图是读取 c 后的情况。后面我不一一绘出,假设后面的读取顺序是d,e,f,g,h,那么缓存会变成如下图所示的结果:
|
||||
|
||||
|
||||
|
||||
这个时候用户如果读取了已经存在的值,比如说c,那么指向c那路箭头会被翻转,下图是read c的结果:
|
||||
|
||||
|
||||
|
||||
这个结果并没有改变下一个更新的位置,但是翻转了指向 c 的路径。 如果要读取x,那么这个时候就会覆盖橘黄色的位置。
|
||||
|
||||
因此,本质上这种树状的方式,其实是在构造一种先入先出的顺序。任何一个节点箭头指向的子节点,应该被先淘汰(最早被使用)。
|
||||
|
||||
这是一个我个人觉得非常天才的设计,因为如果在这个地方构造一个队列,然后每次都把命中的元素的当前位置移动到队列尾部。就至少需要构造一个链表,而链表的每个节点都至少要有当前的值和 next 指针,这就需要创建复杂的数据结构。在内存中创建复杂的数据结构轻而易举,但是在 CPU 中就非常困难。 所以这种基于 bit-tree,就轻松地解决了这个问题。当然,这是一个模拟 LRU 的情况,你还是可以构造出违反 LRU 缓存的顺序。
|
||||
|
||||
27 | 内存回收上篇:如何解决内存的循环引用问题?
|
||||
|
||||
28 | 内存回收下篇:三色标记-清除算法是怎么回事?
|
||||
|
||||
【问题】如果内存太大了,无论是标记还是清除速度都很慢,执行一次完整的 GC 速度下降该如何处理?
|
||||
|
||||
【解析】当应用申请到的内存很大的时候,如果其中内部对象太多。只简单划分几个生代,每个生代占用的内存都很大,这个时候使用 GC 性能就会很糟糕。
|
||||
|
||||
一种参考的解决方案就是将内存划分成很多个小块,类似在应用内部再做一个虚拟内存层。 每个小块可能执行不同的内存回收策略。
|
||||
|
||||
|
||||
|
||||
上图中绿色、蓝色和橘黄色代表 3 种不同的区域。绿色区域中对象存活概率最低(类似 Java 的 Eden),蓝色生存概率上升,橘黄色最高(类似 Java 的老生代)。灰色区域代表应用从操作系统中已经申请了,但尚未使用的内存。通过这种划分方法,每个区域中进行 GC 的开销都大大减少。Java 目前默认的内存回收器 G1,就是采用上面的策略。
|
||||
|
||||
总结
|
||||
|
||||
这个模块我们学习了内存管理。通过内存管理的学习,我希望你开始理解虚拟化的价值,内存管理部分的虚拟化,是一种应对资源稀缺、增加资源流动性的手段(听起来那么像银行印的货币)。
|
||||
|
||||
既然内存资源可以虚拟化,那么计算资源可以虚拟化吗?用户发生大量的请求时,响应用户请求的处理程序可以虚拟化吗?当消息太大的情况下,一个队列可以虚拟化吗?当浏览的页面很大时,用户看到的可视区域可以虚拟化吗?——我觉得这些问题都是值得大家深思的,如果你对这几个问题有什么想法,也欢迎写在留言区,大家一起交流。
|
||||
|
||||
另外,缓存设计部分的重点在于算法的掌握。因为你可以从这些算法中获得很多处理实际问题的思路,服务端同学会反思 MySQL/Redis 的使用,前端同学会反思浏览器缓存、Native 缓存、CDN 的使用。很多时候,工具还会给你提供参数,那么你应该用哪种缓存置换算法,你的目的是什么?我们只学习了如何收集和操作系统相关的性能指标,但当你面对应用的时候,还会碰到更多的指标,这个时候就需要你在实战中继续进步和分析了。
|
||||
|
||||
这个模块还有一个重要的课题,就是内存回收,这块的重点在于理解内存回收器,你需要关注:暂停时间、足迹和吞吐量、实时性,还需要知道如何针对自己的业务场景,分析这几个指标的要求,学会选择不同的 GC 算法,配置不同的 GC 参数。
|
||||
|
||||
|
||||
|
||||
|
182
专栏/重学操作系统-完/28内存回收下篇:三色标记-清除算法是怎么回事?.md
Normal file
182
专栏/重学操作系统-完/28内存回收下篇:三色标记-清除算法是怎么回事?.md
Normal file
@@ -0,0 +1,182 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
28 内存回收下篇:三色标记-清除算法是怎么回事?
|
||||
今天我们继续讨论内存回收问题。在上一讲,我们发现双色标记-清除算法有一个明显的问题,如下图所示:
|
||||
|
||||
|
||||
|
||||
你可以把 GC 的过程看作标记、清除及程序不断对内存进行修改的过程,分成 3 种任务:
|
||||
|
||||
|
||||
标记程序(Mark)
|
||||
清除程序(Sweep)
|
||||
变更程序(Mutation)
|
||||
|
||||
|
||||
标记(Mark)就是找到不用的内存,清除(Sweep)就是回收不用的资源,而修改(Muation)则是指用户程序对内存进行了修改。通常情况下,在 GC 的设计中,上述 3 种程序不允许并行执行(Simultaneously)。对于 Mark、Sweep、Mutation 来说内存是共享的。如果并行执行相当于需要同时处理大量竞争条件的手段,这会增加非常多的开销。当然你可以开多个线程去 Mark、Mutation 或者 Sweep,但前提是每个过程都是独立的。
|
||||
|
||||
|
||||
|
||||
因为 Mark 和 Sweep 的过程都是 GC 管理,而 Mutation 是在执行应用程序,在实时性要求高的情况下可以允许一边 Mark,一边 Sweep 的情况; 优秀的算法设计也可能会支持一边 Mark、一边 Mutation 的情况。这种算法通常使用了 Read On Write 技术,本质就是先把内存拷贝一份去 Mark/Sweep,让 Mutation 完全和 Mark 隔离。
|
||||
|
||||
|
||||
|
||||
上图中 GC 开始后,拷贝了一份内存的原本,进行 Mark 和 Sweep,整理好内存之后,再将原本中所有的 Mutation 合并进新的内存。 这种算法设计起来会非常复杂,但是可以保证实时性 GC。
|
||||
|
||||
上图的这种 GC 设计比较少见,通常 GC 都会发生 STL(Stop The World)问题,Mark/Sweep/Mutation 只能够交替执行。也就是说, 一种程序执行的时候,另一种程序必须停止。
|
||||
|
||||
对于双色标记-清除算法,如果 Mark 和 Sweep 之间存在 Mutation,那么 Mutation 的伤害是比较大的。比如 Mutation 新增了一个白色的对象,这个白色的对象就可能会在 Sweep 启动后被清除。当然也可以考虑新增黑色的对象,这样对象就不会在 Sweep 启动时被回收。但是会发生下面这个问题,如下图所示:
|
||||
|
||||
|
||||
|
||||
如果一个新对象指向了一个已经删除的对象,一个新的黑色对象指向了一个白色对象,这个时候 GC 不会再遍历黑色对象,也就是白色的对象还是会被清除。因此,我们希望创建一个在并发环境更加稳定的程序,让 Mark/Mutation/Sweep 可以交替执行,不用特别在意它们之间的关联。
|
||||
|
||||
有一个非常优雅地实现就是再增加一种中间的灰色,把灰色看作可以增量处理的工作,来重新定义白色的含义。
|
||||
|
||||
三色标记-清除算法(Tri-Color Mark Sweep)
|
||||
|
||||
接下来,我会和你讨论这种有三个颜色标记的算法,通常称作三色标记-清除算法。首先,我们重新定义黑、白、灰三种颜色的含义:
|
||||
|
||||
|
||||
白色代表需要 GC 的对象;
|
||||
黑色代表确定不需要 GC 的对象;
|
||||
灰色代表可能不需要 GC 的对象,但是还未完成标记的任务,也可以认为是增量任务。
|
||||
|
||||
|
||||
在三色标记-清除算法中,一开始所有对象都染成白色。初始化完成后,会启动标记程序。在标记的过程中,是可以暂停标记程序执行 Mutation。
|
||||
|
||||
算法需要维护 3 个集合,白色集合、黑色集合、灰色集合。3 个集合是互斥的,对象只能在一个集合中。执行之初,所有对象都放入白色集合,如下图所示:
|
||||
|
||||
|
||||
|
||||
第一次执行,算法将 Root 集合能直接引用的对象加入灰色集合,如下图所示:
|
||||
|
||||
|
||||
|
||||
接下来算法会不断从灰色集合中取出元素进行标记,主体标记程序如下:
|
||||
|
||||
while greySet.size() > 0 {
|
||||
|
||||
var item = greySet.remove();
|
||||
|
||||
mark(item);
|
||||
|
||||
}
|
||||
|
||||
|
||||
标记的过程主要分为 3 个步骤:
|
||||
|
||||
|
||||
如果对象在白色集合中,那么先将对象放入灰色集合;
|
||||
然后遍历节点的所有的引用对象,并递归所有引用对象;
|
||||
当一个对象的所有引用对象都在灰色集合中,就把这个节点放入为黑色集合。
|
||||
|
||||
|
||||
伪代码如下:
|
||||
|
||||
func mark(obj) {
|
||||
|
||||
if obj in whiteSet {
|
||||
|
||||
greySet.add(obj)
|
||||
|
||||
for v in refs(obj) {
|
||||
|
||||
mark(v)
|
||||
|
||||
}
|
||||
|
||||
greySet.remove(obj)
|
||||
|
||||
blackSet.add(obj)
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
你可以观察下上面的程序,这是一个 DFS 的过程。如果多个线程对不同的 Root Object 并发执行这个算法,我们需要保证 3 个集合都是线程安全的,可以考虑利用 ConcurrentSet(这样性能更好),或者对临界区上锁。并发执行这个算法的时候,如果发现一个灰色节点说明其他线程正在处理这个节点,就忽略这个节点。这样,就解决了标记程序可以并发执行的问题。
|
||||
|
||||
当标记算法执行完成的时候,所有不需要 GC 的元素都会涂黑:
|
||||
|
||||
|
||||
标记算法完成后,白色集合内就是需要回收的对象。
|
||||
|
||||
以上,是类似双色标记-清除算法的全量 GC 程序,我们从 Root 集合开始遍历,完成了对所有元素的标记(将它们放入对应的集合)。
|
||||
|
||||
接下来我们来考虑增加 GC(Incremental GC)的实现。首先对用户的修改进行分类,有这样 3 类修改(Mutation)需要考虑:
|
||||
|
||||
|
||||
创建新对象
|
||||
删除已有对象
|
||||
调整已有引用
|
||||
|
||||
|
||||
如果用户程序创建了新对象,可以考虑把新对象直接标记为灰色。虽然,也可以考虑标记为黑色,但是标记为灰色可以让 GC 意识到新增了未完成的任务。比如用户创建了新对象之后,新对象引用了之前删除的对象,就需要重新标记创建的部分。
|
||||
|
||||
如果用户删除了已有的对象,通常做法是等待下一次全量 Mark 算法处理。下图中我们删除了 Root Object 到 A 的引用,这个时候如果把 A 标记成白色,那么还需要判断是否还有其他路径引用到 A,而且 B,C 节点的颜色也需要重新计算。关键的问题是,虽然可以实现一个基于 A 的 DFS 去解决这个问题,但实际情况是我们并不着急解决这个问题,因为内存空间往往是有富余的。
|
||||
|
||||
|
||||
|
||||
在调整已有的引用关系时,三色标记算法的表现明显更好。下图是对象 B 将对 C 的引用改成了对 F 的引用,C,F 被加入灰色集合。接下来 GC 会递归遍历 C,F,最终然后 F,E,G 都会进入灰色集合。
|
||||
|
||||
|
||||
|
||||
内存回收就好比有人在随手扔垃圾,清洁工需要不停打扫。如果清洁工能够跟上人们扔垃圾的速度,那么就不需要太多的 STL(Stop The World)。如果清洁工跟不上扔垃圾的速度,最终环境就会被全部弄乱,这个时候清洁工就会要求“Stop The World”。三色算法的优势就在于它支持多一些情况的 Mutation,这样能够提高“垃圾”被并发回收的概率。
|
||||
|
||||
目前的 GC 主要都是基于三色标记算法。 至于清除算法,有原地回收算法,也有把存活下来的对象(黑色对象)全部拷贝到一个新的区域的算法。
|
||||
|
||||
碎片整理和生代技术
|
||||
|
||||
三色标记-清除算法,还没有解决内存回收产生碎片的问题。通常,我们会在三色标记-清除算法之上,再构建一个整理内存(Compact)的算法。如下图所示:
|
||||
|
||||
|
||||
Compact 算法将对象重新挤压到一起,让更多空间可以被使用。我们在设计这个算法时,观察到了一个现象:新创建出来的对象,死亡(被回收)概率会更高,而那些已经存在了一段时间的对象,往往更不容易死亡。这有点类似 LRU 缓存,其实是一个概率问题。接下来我们考虑针对这个现象进行优化。
|
||||
|
||||
|
||||
|
||||
如上图所示,你可以把新创建的对象,都先放到一个统一的区域,在 Java 中称为伊甸园(Eden)。这个区域因为频繁有新对象死亡,因此需要经常 GC。考虑整理使用中的对象成本较高,因此可以考虑将存活下来的对象拷贝到另一个区域,Java 中称为存活区(Survior)。存活区生存下来的对象再进入下一个区域,Java 中称为老生代。
|
||||
|
||||
上图展示的三个区域,Eden、Survior 及老生代之间的关系是对象的死亡概率逐级递减,对象的存活周期逐级增加。三个区域都采用三色标记-清除算法。每次 Eden 存活下来的对象拷贝到 Survivor 区域之后,Eden 就可以完整的回收重利用。Eden 可以考虑和 Survivor 用 1:1 的空间,老生代则可以用更大的空间。Eden 中全量 GC 可以频繁执行,也可以增量 GC 混合全量 GC 执行。老生代中的 GC 频率可以更低,偶尔执行一次全量的 GC。
|
||||
|
||||
GC 的选择
|
||||
|
||||
最后我们来聊聊 GC 的选择。通常选择 GC 会有实时性要求(最大容忍的暂停时间),需要从是否为高并发场景、内存实际需求等维度去思考。在选择 GC 的时候,复杂的算法并不一定更有效。下面是一些简单有效的思考和判断。
|
||||
|
||||
|
||||
|
||||
|
||||
如果你的程序内存需求较小,GC 压力小,这个时候每次用双色标记-清除算法,等彻底标记-清除完再执行应用程序,用户也不会感觉到多少延迟。双色标记-清除算法在这种场景可能会更加节省时间,因为程序简单。
|
||||
对于一些对暂停时间不敏感的应用,比如说数据分析类应用,那么选择一个并发执行的双色标记-清除算法的 GC 引擎,是一个非常不错的选择。因为这种应用 GC 暂停长一点时间都没有关系,关键是要最短时间内把整个 GC 执行完成。
|
||||
如果内存的需求大,同时对暂停时间也有要求,就需要三色标记清除算法,让部分增量工作可以并发执行。
|
||||
如果在高并发场景,内存被频繁迭代,这个时候就需要生代算法。将内存划分出不同的空间,用作不同的用途。
|
||||
如果实时性要求非常高,就需要选择专门针对实时场景的 GC 引擎,比如 Java 的 Z。
|
||||
|
||||
|
||||
当然,并不是所有的语言都提供多款 GC 选择。但是通常每个语言都会提供很多的 GC 参数。这里也有一些最基本的思路,下面我为你介绍一下。
|
||||
|
||||
如果内存不够用,有两种解决方案。一种是降低吞吐量——相当于 GC 执行时间上升;另一种是增加暂停时间,暂停时间较长,GC 更容易集中资源回收内存。那么通常语言的 GC 都会提供设置吞吐量和暂停时间的 API。
|
||||
|
||||
如果内存够用,有的 GC 引擎甚至会选择当内存达到某个阈值之后,再启动 GC 程序。通常阈值也是可以调整的。因此如果内存够用,就建议让应用使用更多的内存,提升整体的效率。
|
||||
|
||||
总结
|
||||
|
||||
那么通过这节课的学习,你现在可以尝试来回答本节关联的 2 道面试题目:
|
||||
|
||||
|
||||
如何解决内存的循环引用问题?
|
||||
三色标记清除算法的工作原理?
|
||||
|
||||
|
||||
【解析】 解决循环引用的问题可以考虑利用 Root Tracing 类的 GC 算法。从根集合利用 DFS 或者 BFS 遍历所有子节点,最终不能和根集合连通的节点都是需要回收的。
|
||||
|
||||
三色标记算法利用三种颜色进行标记。白色代表需要回收的节点;黑色代表不需要回收的节点;灰色代表会被回收,但是没有完成标记的节点。
|
||||
|
||||
初始化的时候所有节点都标记为白色,然后利用 DFS 从 Root 集合遍历所有节点。每遍历到一个节点就把这个节点放入灰色集合,如果这个节点所有的子节点都遍历完成,就把这个节点放入黑色的集合。最后白色集合中剩下的就是需要回收的元素。
|
||||
|
||||
|
||||
|
||||
|
129
专栏/重学操作系统-完/29Linux下的各个目录有什么作用?.md
Normal file
129
专栏/重学操作系统-完/29Linux下的各个目录有什么作用?.md
Normal file
@@ -0,0 +1,129 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
29 Linux 下的各个目录有什么作用?
|
||||
今天我们开始学习模块六:文件系统。学习文件系统的意义在于文件系统有很多设计思路可以迁移到实际的工作场景中,比如:
|
||||
|
||||
|
||||
MySQL 的 binlog 和 Redis AOF 都像极了日志文件系统的设计;
|
||||
B Tree 用于加速磁盘数据访问的设计,对于索引设计也有通用的意义。
|
||||
|
||||
|
||||
特别是近年来分布式系统的普及,学习分布式文件系统,也是理解分布式架构最核心的一个环节。其实文件系统最精彩的还是虚拟文件系统的设计,比如 Linux 可以支持每个目录用不同的文件系统。这些文件看上去是一个个目录和文件,实际上可能是磁盘、内存、网络文件系统、远程磁盘、网卡、随机数产生器、输入输出设备等,这样虚拟文件系统就成了整合一切设备资源的平台。大量的操作都可以抽象成对文件的操作,程序的书写就会完整而统一,且扩展性强。
|
||||
|
||||
这一讲,我会从 Linux 的目录结构和用途开始,带你认识 Linux 的文件系统。Linux 所有的文件都建立在虚拟文件系统(Virtual File System ,VFS)之上,如下图所示:
|
||||
|
||||
|
||||
|
||||
当你访问一个目录或者文件,虽然用的是 Linux 标准的文件 API 对文件进行操作,但实际操作的可能是磁盘、内存、网络或者数据库等。因此,Linux 上不同的目录可能是不同的磁盘,不同的文件可能是不同的设备。
|
||||
|
||||
分区结构
|
||||
|
||||
在 Linux 中,/是根目录。之前我们在“08 讲”提到过,每个目录可以是不同的文件系统(不同的磁盘或者设备)。你可能会问我,/是对应一个磁盘还是多个磁盘呢?在/创建目录的时候,目录属于哪个磁盘呢?
|
||||
|
||||
|
||||
|
||||
你可以用df -h查看上面两个问题的答案,在上图中我的/挂载到了/dev/sda5上。如果你想要看到更多信息,可以使用df -T,如下图所示:
|
||||
|
||||
|
||||
|
||||
/的文件系统类型是ext4。这是一种常用的日志文件系统。关于日志文件系统,我会在“30 讲”为你介绍。然后你可能还会有一个疑问,/dev/sda5究竟是一块磁盘还是别的什么?这个时候你可以用fdisk -l查看,结果如下图:
|
||||
|
||||
|
||||
|
||||
你可以看到我的 Linux 虚拟机上,有一块 30G 的硬盘(当然是虚拟的)。然后这块硬盘下有 3 个设备(Device):/dev/sda1, /dev/sda2 和 /dev/sda5。在 Linux 中,数字 1~4 结尾的是主分区,通常一块磁盘最多只能有 4 个主分区用于系统启动。主分区之下,还可以再分成若干个逻辑分区,4 以上的数字都是逻辑分区。因此/dev/sda2和/dev/sda5是主分区包含逻辑分区的关系。
|
||||
|
||||
挂载
|
||||
|
||||
分区结构最终需要最终挂载到目录上。上面例子中/dev/sda5分区被挂载到了/下。 这样在/创建的文件都属于这个/dev/sda5分区。 另外,/dev/sda5采用ext4文件系统。可见不同的目录可以采用不同的文件系统。
|
||||
|
||||
将一个文件系统映射到某个目录的过程叫作挂载(Mount)。当然这里的文件系统可以是某个分区、某个 USB 设备,也可以是某个读卡器等。你可以用mount -l查看已经挂载的文件系统。
|
||||
|
||||
|
||||
|
||||
上图中的sysfsprocdevtmpfstmpfsext4都是不同的文件系统,下面我们来说说它们的作用。
|
||||
|
||||
|
||||
sysfs让用户通过文件访问和设置设备驱动信息。
|
||||
proc是一个虚拟文件系统,让用户可以通过文件访问内核中的进程信息。
|
||||
devtmpfs在内存中创造设备文件节点。
|
||||
tmpfs用内存模拟磁盘文件。
|
||||
ext4是一个通常意义上我们认为的文件系统,也是管理磁盘上文件用的系统。
|
||||
|
||||
|
||||
你可以看到挂载记录中不仅有文件系统类型,挂载的目录(on 后面部分),还有读写的权限等。你也可以用mount指令挂载一个文件系统到某个目录,比如说:
|
||||
|
||||
mount /dev/sda6 /abc
|
||||
|
||||
|
||||
上面这个命令将/dev/sda6挂载到目录abc。
|
||||
|
||||
目录结构
|
||||
|
||||
因为 Linux 内文件系统较多,用途繁杂,Linux 对文件系统中的目录进行了一定的归类,如下图所示:
|
||||
|
||||
|
||||
|
||||
最顶层的目录称作根目录, 用/表示。/目录下用户可以再创建目录,但是有一些目录随着系统创建就已经存在,接下来我会和你一起讨论下它们的用途。
|
||||
|
||||
/bin(二进制)包含了许多所有用户都可以访问的可执行文件,如 ls, cp, cd 等。这里的大多数程序都是二进制格式的,因此称作bin目录。bin是一个命名习惯,比如说nginx中的可执行文件会在 Nginx 安装目录的 bin 文件夹下面。
|
||||
|
||||
/dev(设备文件) 通常挂载在devtmpfs文件系统上,里面存放的是设备文件节点。通常直接和内存进行映射,而不是存在物理磁盘上。
|
||||
|
||||
值得一提的是其中有几个有趣的文件,它们是虚拟设备。
|
||||
|
||||
/dev/null是可以用来销毁任何输出的虚拟设备。你可以用>重定向符号将任何输出流重定向到/dev/null来忽略输出的结果。
|
||||
|
||||
/dev/zero是一个产生数字 0 的虚拟设备。无论你对它进行多少次读取,都会读到 0。
|
||||
|
||||
/dev/ramdom是一个产生随机数的虚拟设备。读取这个文件中数据,你会得到一个随机数。你不停地读取这个文件,就会得到一个随机数的序列。
|
||||
|
||||
/etc(配置文件),/etc名字的含义是and so on……,也就是“等等及其他”,Linux 用它来保管程序的配置。比如说mysql通常会在/etc/mysql下创建配置。再比如说/etc/passwd是系统的用户配置,存储了用户信息。
|
||||
|
||||
/proc(进程和内核文件) 存储了执行中进程和内核的信息。比如你可以通过/proc/1122目录找到和进程1122关联的全部信息。还可以在/proc/cpuinfo下找到和 CPU 相关的全部信息。
|
||||
|
||||
/sbin(系统二进制) 和/bin类似,通常是系统启动必需的指令,也可以包括管理员才会使用的指令。
|
||||
|
||||
/tmp(临时文件) 用于存放应用的临时文件,通常用的是tmpfs文件系统。因为tmpfs是一个内存文件系统,系统重启的时候清除/tmp文件,所以这个目录不能放应用和重要的数据。
|
||||
|
||||
/var (Variable data file,,可变数据文件) 用于存储运行时的数据,比如日志通常会存放在/var/log目录下面。再比如应用的缓存文件、用户的登录行为等,都可以放到/var目录下,/var下的文件会长期保存。
|
||||
|
||||
/boot(启动) 目录下存放了 Linux 的内核文件和启动镜像,通常这个目录会写入磁盘最头部的分区,启动的时候需要加载目录内的文件。
|
||||
|
||||
/opt(Optional Software,可选软件) 通常会把第三方软件安装到这个目录。以后你安装软件的时候,可以考虑在这个目录下创建。
|
||||
|
||||
/root(root 用户家目录) 为了防止误操作,Linux 设计中 root 用户的家目录没有设计在/home/root下,而是放到了/root目录。
|
||||
|
||||
/home(家目录) 用于存放用户的个人数据,比如用户lagou的个人数据会存放到/home/lagou下面。并且通常在用户登录,或者执行cd指令后,都会在家目录下工作。 用户通常会对自己的家目录拥有管理权限,而无法访问其他用户的家目录。
|
||||
|
||||
/media(媒体) 自动挂载的设备通常会出现在/media目录下。比如你插入 U 盘,通常较新版本的 Linux 都会帮你自动完成挂载,也就是在/media下创建一个目录代表 U 盘。
|
||||
|
||||
/mnt(Mount,挂载) 我们习惯把手动挂载的设备放到这个目录。比如你插入 U 盘后,如果 Linux 没有帮你完成自动挂载,可以用mount命令手动将 U 盘内容挂载到/mnt目录下。
|
||||
|
||||
/svr(Service Data,,服务数据) 通常用来存放服务数据,比如说你开发的网站资源文件(脚本、网页等)。不过现在很多团队的习惯发生了变化, 有的团队会把网站相关的资源放到/www目录下,也有的团队会放到/data下。总之,在存放资源的角度,还是比较灵活的。
|
||||
|
||||
/usr(Unix System Resource) 包含系统需要的资源文件,通常应用程序会把后来安装的可执行文件也放到这个目录下,比如说
|
||||
|
||||
|
||||
vim编辑器的可执行文件通常会在/usr/bin目录下,区别于ls会在/bin目录下
|
||||
/usr/sbin中会包含有通常系统管理员才会使用的指令。
|
||||
/usr/lib目录中存放系统的库文件,比如一些重要的对象和动态链接库文件。
|
||||
/usr/lib目录下会有大量的.so文件,这些叫作Shared Object,类似windows下的dll文件。
|
||||
/usr/share目录下主要是文档,比如说 man 的文档都在/usr/share/man下面。
|
||||
|
||||
|
||||
总结
|
||||
|
||||
这一讲我们了解了 Linux 虚拟文件系统的设计,并且熟悉了 Linux 的目录结构。我曾经看到不少程序员把程序装到了/home目录,也看到过不少程序员将数据放到了/root目录。这样做并不会带来致命性问题,但是会给其他和你一起工作的同事带来困扰。
|
||||
|
||||
今天我们讲到的这些规范是整个世界通用的,如果每个人都能遵循规范的原则,工作起来就会有很好的默契。登录一台linux服务器,你可以通过目录结构快速熟悉。你可以查阅/etc下的配置,看看/opt下装了什么软件,这就是规范的好处。
|
||||
|
||||
那么通过这节课的学习,你现在可以尝试来回答本节标题中的试题目:Linux下各个目录有什么作用了吗?
|
||||
|
||||
【解析】通常面试官会挑选其中一部分对你进行抽查,如果你快要面试了,再 Review 一下本讲的内容吧。
|
||||
|
||||
|
||||
|
||||
|
148
专栏/重学操作系统-完/30文件系统的底层实现:FAT、NTFS和Ext3有什么区别?.md
Normal file
148
专栏/重学操作系统-完/30文件系统的底层实现:FAT、NTFS和Ext3有什么区别?.md
Normal file
@@ -0,0 +1,148 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
30 文件系统的底层实现:FAT、NTFS 和 Ext3 有什么区别?
|
||||
这一讲给你带来的面试题是: FAT、NTFS 和 Ext3 文件系统有什么区别?
|
||||
|
||||
10 年前 FAT 文件系统还是常见的格式,而现在 Windows 上主要是 NTFS,Linux 上主要是 Ext3、Ext4 文件系统。关于这块知识,一般资料只会从支持的磁盘大小、数据保护、文件名等各种维度帮你比较,但是最本质的内容却被一笔带过。它们最大的区别是文件系统的实现不同,具体怎么不同?文件系统又有哪些实现?这一讲,我将带你一起来探索和学习这部分知识。
|
||||
|
||||
硬盘分块
|
||||
|
||||
在了解文件系统实现之前,我们先来了解下操作系统如何使用硬盘。
|
||||
|
||||
使用硬盘和使用内存有一个很大的区别,内存可以支持到字节级别的随机存取,而这种情况在硬盘中通常是不支持的。过去的机械硬盘内部是一个柱状结构,有扇区、柱面等。读取硬盘数据要转动物理的磁头,每转动一次磁头时间开销都很大,因此一次只读取一两个字节的数据,非常不划算。
|
||||
|
||||
随着 SSD 的出现,机械硬盘开始逐渐消失(还没有完全结束),现在的固态硬盘内部是类似内存的随机存取结构。但是硬盘的读写速度还是远远不及内存。而连续读多个字节的速度,还远不如一次读一个硬盘块的速度。
|
||||
|
||||
因此,为了提高性能,通常会将物理存储(硬盘)划分成一个个小块,比如每个 4KB。这样做也可以让硬盘的使用看起来非常整齐,方便分配和回收空间。况且,数据从磁盘到内存,需要通过电子设备,比如 DMA、总线等,如果一个字节一个字节读取,速度较慢的硬盘就太耗费时间了。过去的机械硬盘的速度可以比内存慢百万倍,现在的固态硬盘,也会慢几十到几百倍。即便是最新的 NvMe 接口的硬盘,和内存相比速度仍然有很大的差距。因此,一次读/写一个块(Block)才是可行的方案。
|
||||
|
||||
|
||||
|
||||
如上图所示,操作系统会将磁盘分成很多相等大小的块。这样做还有一个好处就是如果你知道块的序号,就可以准确地计算出块的物理位置。
|
||||
|
||||
文件的描述
|
||||
|
||||
我们将硬盘分块后,如何利用上面的硬盘存储文件,就是文件系统(File System)要负责的事情了。当然目录也是一种文件,因此我们先讨论文件如何读写。不同的文件系统利用方式不同,今天会重点讨论 3 种文件系统:
|
||||
|
||||
|
||||
早期的 FAT 格式
|
||||
基于 inode 的传统文件系统
|
||||
日志文件系统(如 NTFS, EXT2、3、4)
|
||||
|
||||
|
||||
FAT 表
|
||||
|
||||
早期人们找到了一种方案就是文件分配表(File Allocate Table,FAT)。如下图所示:
|
||||
|
||||
|
||||
|
||||
一个文件,最基本的就是要描述文件在硬盘中到底对应了哪些块。FAT 表通过一种类似链表的结构描述了文件对应的块。上图中:文件 1 从位置 5 开始,这就代表文件 1 在硬盘上的第 1 个块的序号是 5 的块 。然后位置 5 的值是 2,代表文件 1 的下一个块的是序号 2 的块。顺着这条链路,我们可以找到 5 → 2 → 9 → 14 → 15 → -1。-1 代表结束,所以文件 1 的块是:5,2,9,14,15。同理,文件 2 的块是 3,8,12。
|
||||
|
||||
FAT 通过一个链表结构解决了文件和物理块映射的问题,算法简单实用,因此得到过广泛的应用,到今天的 Windows/Linux/MacOS 都还支持 FAT 格式的文件系统。FAT 的缺点就是非常占用内存,比如 1T 的硬盘,如果块的大小是 1K,那么就需要 1G 个 FAT 条目。通常一个 FAT 条目还会存一些其他信息,需要 2~3 个字节,这就又要占用 2-3G 的内存空间才能用 FAT 管理 1T 的硬盘空间。显然这样做是非常浪费的,问题就出在了 FAT 表需要全部维护在内存当中。
|
||||
|
||||
索引节点(inode)
|
||||
|
||||
为了改进 FAT 的容量限制问题,可以考虑为每个文件增加一个索引节点(inode)。这样,随着虚拟内存的使用,当文件导入内存的时候,先导入索引节点(inode),然后索引节点中有文件的全部信息,包括文件的属性和文件物理块的位置。
|
||||
|
||||
|
||||
|
||||
如上图,索引节点除了属性和块的位置,还包括了一个指针块的地址。这是为了应对文件非常大的情况。一个大文件,一个索引节点存不下,需要通过指针链接到其他的块去描述文件。
|
||||
|
||||
这种文件索引节点(inode)的方式,完美地解决了 FAT 的缺陷,一直被沿用至今。FAT 要把所有的块信息都存在内存中,索引节点只需要把用到的文件形成数据结构,而且可以使用虚拟内存分配空间,随着页表置换,这就解决了 FAT 的容量限制问题。
|
||||
|
||||
目录的实现
|
||||
|
||||
有了文件的描述,接下来我们来思考如何实现目录(Directory)。目录是特殊的文件,所以每个目录都有自己的 inode。目录是文件的集合,所以目录的内容中必须有所有其下文件的 inode 指针。
|
||||
|
||||
|
||||
|
||||
文件名也最好不要放到 inode 中,而是放到文件夹中。这样就可以灵活设置文件的别名,及实现一个文件同时在多个目录下。
|
||||
|
||||
|
||||
|
||||
如上图,/foo 和 /bar 两个目录中的 b.txt 和 c.txt 其实是一个文件,但是拥有不同的名称。这种形式我们称作“硬链接”,就是多个文件共享 inode。
|
||||
|
||||
|
||||
|
||||
硬链接有一个非常显著的特点,硬链接的双方是平等的。上面的程序我们用ln指令为文件 a 创造了一个硬链接b。如果我们创造完删除了 a,那么 b 也是可以正常工作的。如果要删除掉这个文件的 inode,必须 a,b 同时删除。这里你可以看出 a,b 是平等的。
|
||||
|
||||
和硬链接相对的是软链接,软链接的原理如下图:
|
||||
|
||||
|
||||
|
||||
图中c.txt是b.txt的一个软链接,软链接拥有自己的inode,但是文件内容就是一个快捷方式。因此,如果我们删除了b.txt,那么b.txt对应的 inode 也就被删除了。但是c.txt依然存在,只不过指向了一个空地址(访问不到)。如果删除了c.txt,那么不会对b.txt造成任何影响。
|
||||
|
||||
在 Linux 中可以通过ln -s创造软链接。
|
||||
|
||||
ln -s a b # 将b设置为a的软链接(b是a的快捷方式)
|
||||
|
||||
|
||||
以上,我们对文件系统的实现有了一个初步的了解。从整体设计上,本质还是将空间切块,然后划分成目录和文件管理这些分块。读、写文件需要通过 inode 操作磁盘。操作系统提供的是最底层读写分块的操作,抽象成文件就交给文件系统。比如想写入第 10001 个字节,那么会分成这样几个步骤:
|
||||
|
||||
|
||||
修改内存中的数据
|
||||
计算要写入第几个块
|
||||
查询 inode 找到真实块的序号
|
||||
将这个块的数据完整的写入一次磁盘
|
||||
|
||||
|
||||
你可以思考一个问题,如果频繁读写磁盘,上面这个模型会有什么问题?可以把你的思考和想法写在留言区,我们在本讲后面会详细讨论。
|
||||
|
||||
解决性能和故障:日志文件系统
|
||||
|
||||
在传统的文件系统实现中,inode 解决了 FAT 容量限制问题,但是随着 CPU、内存、传输线路的速度越来越快,对磁盘读写性能的要求也越来越高。传统的设计,每次写入操作都需要进行一次持久化,所谓“持久化”就是将数据写入到磁盘,这种设计会成为整个应用的瓶颈。因为磁盘速度较慢,内存和 CPU 缓存的速度非常快,如果 CPU 进行高速计算并且频繁写入磁盘,那么就会有大量线程阻塞在等待磁盘 I/O 上。磁盘的瓶颈通常在写入上,因为通常读取数据的时候,会从缓存中读取,不存在太大的瓶颈。
|
||||
|
||||
加速写入的一种方式,就是利用缓冲区。
|
||||
|
||||
|
||||
|
||||
上图中所有写操作先存入缓冲区,然后每过一定的秒数,才进行一次持久化。 这种设计,是一个很好的思路,但最大的问题在于容错。 比如上图的步骤 1 或者步骤 2 只执行了一半,如何恢复?如果步骤 2 只写入了一半,那么数据就写坏了。如果步骤 1 只写入了一半,那么数据就丢失了。无论出现哪种问题,都不太好处理。更何况写操作和写操作之间还有一致性问题,比如说一次删除 inode 的操作后又发生了写入……
|
||||
|
||||
解决上述问题的一个非常好的方案就是利用日志。假设 A 是文件中某个位置的数据,比起传统的方案我们反复擦写 A,日志会帮助我们把 A 的所有变更记录下来,比如:
|
||||
|
||||
A=1
|
||||
|
||||
A=2
|
||||
|
||||
A=3
|
||||
|
||||
|
||||
上面 A 写入了 3 次,因此有 3 条日志。日志文件系统文件中存储的就是像上面那样的日志,而不是文件真实的内容。当用户读取文件的时候,文件内容会在内存中还原,所以内存中 A 的值是 3,但实际磁盘上有 3 条记录。
|
||||
|
||||
从性能上分析,如果日志造成了 3 倍的数据冗余,那么读取的速度并不会真的慢三倍。因为我们多数时候是从内存和 CPU 缓存中读取数据。而写入的时候,因为采用日志的形式,可以考虑下图这种方式,在内存缓冲区中积累一批日志才写入一次磁盘。
|
||||
|
||||
|
||||
|
||||
上图这种设计可以让写入变得非常快速,多数时间都是写内存,最后写一次磁盘。而上图这样的设计成不成立,核心在能不能解决容灾问题。
|
||||
|
||||
你可以思考一下这个问题——丢失一批日志和丢失一批数据的差别大不大。其实它们之间最大的差别在于,如果丢失一批日志,只不过丢失了近期的变更;但如果丢失一批数据,那么就可能造成永久伤害。
|
||||
|
||||
举个例子,比如说你把最近一天的订单数据弄乱了,你可以通过第三方支付平台的交易流水、系统的支付记录等帮助用户恢复数据,还可以通过订单关联的用户信息查询具体是哪些用户的订单出了问题。但是如果你随机删了一部分订单, 那问题就麻烦了。你要去第三发支付平台调出所有流水,用大数据引擎进行分析和计算。
|
||||
|
||||
为了进一步避免损失,一种可行的方案就是创建还原点(Checkpoint),比如说系统把最近 30s 的日志都写入一个区域中。下一个 30s 的日志,写入下一个区域中。每个区域,我们称作一个还原点。创建还原点的时候,我们将还原点涂成红色,写入完成将还原点涂成绿色。
|
||||
|
||||
|
||||
|
||||
如上图,当日志文件系统写入磁盘的时候,每隔一段时间就会把这段时间内的所有日志写入一个或几个连续的磁盘块,我们称为还原点(Checkpoint)。操作系统读入文件的时候,依次读入还原点的数据,如果是绿色,那么就应用这些日志,如果是红色,就丢弃。所以上图中还原点 3 的数据是不完整的,这个时候会丢失不到 30s 的数据。如果将还原点的间隔变小,就可以控制风险的粒度。另外,我们还可以对还原点 3 的数据进行深度恢复,这里可以有人工分析,也可以通过一些更加复杂的算法去恢复。
|
||||
|
||||
总结
|
||||
|
||||
这一讲我们学习了 3 种文件系统的实现,我们再来一起总结回顾一下。
|
||||
|
||||
|
||||
FAT 的设计简单高效,如果你要自己管理一定的空间,可以优先考虑这种设计。
|
||||
inode 的设计在内存中创造了一棵树状结构,对文件、目录进行管理,并且索引到磁盘中的数据。这是一种经典的数据结构,这种思路会被数据库设计、网络资源管理、缓存设计反复利用。
|
||||
日志文件系统——日志结构简单、容易存储、按时间容易分块,这样的设计非常适合缓冲、批量写入和故障恢复。
|
||||
|
||||
|
||||
现在我们很多分布式系统的设计也是基于日志,比如 MySQL 同步数据用 binlog,Redis 的 AOF,著名的分布式一致性算法 Paxos ,因此 Zookeeper 内部也在通过实现日志的一致性来实现分布式一致性。
|
||||
|
||||
那么通过这节课的学习,你现在可以尝试来回答本节关联的面试题目:FAT、NTFS 和 Ext3 有什么区别?
|
||||
|
||||
【解析】FAT 通过内存中一个类似链表的结构,实现对文件的管理。NTFS 和 Ext3 是日志文件系统,它们和 FAT 最大的区别在于写入到磁盘中的是日志,而不是数据。日志文件系统会先把日志写入到内存中一个高速缓冲区,定期写入到磁盘。日志写入是追加式的,不用考虑数据的覆盖。一段时间内的日志内容,会形成还原点。这种设计大大提高了性能,当然也会有一定的数据冗余。
|
||||
|
||||
|
||||
|
||||
|
0
专栏/重学操作系统-完/31数据库文件系统实例:MySQL中B树和B+树有什么区别?.md
Normal file
0
专栏/重学操作系统-完/31数据库文件系统实例:MySQL中B树和B+树有什么区别?.md
Normal file
89
专栏/重学操作系统-完/32(1)加餐练习题详解(六).md
Normal file
89
专栏/重学操作系统-完/32(1)加餐练习题详解(六).md
Normal file
@@ -0,0 +1,89 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
32 (1)加餐 练习题详解(六)
|
||||
今天我会带你把《模块六:文件系统》中涉及的课后练习题,逐一讲解,并给出每个课时练习题的解题思路和答案。
|
||||
|
||||
练习题详解
|
||||
|
||||
29 | Linux下各个目录有什么作用?
|
||||
|
||||
【问题】socket 文件都存在哪里?
|
||||
|
||||
【解析】socket 没有实体文件,只有 inode,所以 socket 是没有名字的文件。
|
||||
|
||||
你可以在 /proc/net/tcp 目录下找到所有的 TCP 连接,在 /proc/[pid]/fd 下也可以找到这些 socket 文件,都是数字代号,数字就是 socket 文件的 fd,如下图所示:
|
||||
|
||||
|
||||
|
||||
你也可以用lsof -i -a -p [pid查找某个进程的 socket 使用情况。下面结果和你用ls /proc/[pid]/fd看到的 fd 是一致的,如下图所示:
|
||||
|
||||
|
||||
|
||||
30 | 文件系统的底层实现:FAT、NTFS 和 Ext3 有什么区别?
|
||||
|
||||
【问题】思考日志文件系统的数据冗余如何处理?
|
||||
|
||||
【解析】日志系统产生冗余几乎是必然发生的。 只要发生了修改、删除,肯定就会有数据冗余。日志系统通常不会主动压缩,但是日志文件系统通常会对磁盘碎片进行整理,这种机制和内存的管理非常相似。
|
||||
|
||||
首先我们把这个磁盘切割成很多等大的小块,大文件可能需要分配多个小块,多个小文件共用一个小块。而当很多文件被删除之后,磁盘中的小块会产生碎片,文件系统会进行碎片整理,比如把多个有很多碎片的区域拷贝到一起,使存储空间更加紧凑。
|
||||
|
||||
回到正题,最终的答案就是不压缩、不处理冗余,空间换时间,提升写入速度。
|
||||
|
||||
31 | 数据库文件系统实例:MySQL 中 B 树和 B+ 树有什么区别?
|
||||
|
||||
【问题】按照应该尽量减少磁盘读写操作的原则,是不是哈希表的索引更有优势?
|
||||
|
||||
【解析】哈希表是一种稀疏的离散结构,通常使用键查找值。给定一个键,哈希表会通过数学计算的方式找到值的内存地址。因此,从这个角度去分析,哈希表的查询速度非常快。单独查找某一个数据速度超过了 B+ 树(比如根据姓名查找用户)。因此,包括 MySQL 在内的很多数据库,在支持 B+ 树索引的同时,也支持哈希表索引。
|
||||
|
||||
这两种索引最大的区别是:B+ 树是对范围的划分,其中的数据还保持着连续性;而哈希表是一种离散的查询结构,数据已经分散到不同的空间中去了。所以当数据要进行范围查找时,比如查找某个区间内的订单,或者进行聚合运算,这个时候哈希表的性能就非常低了。
|
||||
|
||||
哈希表有一个设计约束,如果我们用了 m 个桶(Bucket,比如链表)去存储哈希表中的数据,再假设总共需要存储 N 个数据。那么平均查询次数 k = N/m。为了让 k 不会太大,当数据增长到一定规模时,哈希表需要增加桶的数目,这个时候就需要重新计算所有节点的哈希值(重新分配所有节点属于哪个桶)。
|
||||
|
||||
综上,对于大部分的操作 B+ 树都有较好的性能,比如说 >,<, =,BETWEEN,LIKE 等,哈希表只能用于等于的情况。
|
||||
|
||||
32 | HDFS 介绍:分布式文件系统是怎么回事?
|
||||
|
||||
【问题】Master 节点如果宕机了,影响有多大,如何恢复?
|
||||
|
||||
【解析】在早期的设计中,Master 是一个单点(Single Point),如果发生故障,系统就会停止运转,这就是所谓的单点故障(Single Point of Failure)。由此带来的后果会非常严重。发生故障后,虽然我们可以设置第二节点不断备份还原点,通过还原点加快系统恢复的速度,但是在数据的恢复期间,整个系统是不可用的。
|
||||
|
||||
在一个高可用的设计当中,我们不希望发生任何的单点故障(SPoF),因此所有的节点都至少有两份。于是在 Hadoop 后来的设计当中,增加了一种主从结构。
|
||||
|
||||
|
||||
如上图所示,我们同时维护两个 Master 节点(在 Hadoop 中称为 NameNode,NN)——一个活动(Active)的 NN 节点,一个待命(StandBy)的 NN 节点。
|
||||
|
||||
为了保证在系统出现故障的时候,可以迅速切换节点,我们需要一个故障控制单元。因为是分布式的设计,控制单元在每个 NN 中都必须有一个,这个单元可以考虑 NN 节点进程中的一个线程。控制单元不断地检测节点的状态,并且不断探测其他 NN 节点的状态。一旦检测到故障,控制单元随时准备切换节点。
|
||||
|
||||
一方面,因为我们不能信任任何的 NN 节点不出现故障,所以不能将节点的状态存在任何一个 NN 节点中。并且节点的状态也不适合存在数据节点中,因为大数据集群的数据节点实时性不够,它是用来存储大文件的。因此,可以考虑将节点的状态放入一个第三方的存储当中,通常就是 ZooKeeper。
|
||||
|
||||
另一方面,因为活动 NN 节点和待命 NN 节点数据需要完全一致,所以数据节点也会把自己的状态同时发送给活动节点和待命节点(比如命名空间变动等)。最后客户端会把请求发送给活动节点,因此活动节点会产生操作日志。不可以把活动节点的操作日志直接发送给待命节点,是因为我们不确定待命节点是否可用。
|
||||
|
||||
而且,为了保证日志数据不丢失,它们应该存储至少 3 份。即使其中一份数据发生损坏,也可以通过对比半数以上的节点(2 个)恢复数据。因此,这里需要设计专门的日志节点(Journal Node)存储日志。至少需要 3 个日志节点,而且必须是奇数。活动节点将自己的日志发送给日志节点,待命节点则从日志节点中读取日志,同步自己的状态。
|
||||
|
||||
我们再来回顾一下这个高可用的设计。为了保证可用性,我们增加了备用节点待命,随时替代活动节点。为了达成这个目标。有 3 类数据需要同步。
|
||||
|
||||
|
||||
数据节点同步给主节点的日志。这类数据由数据节点同时同步给活动、待命节点。
|
||||
活动节点同步给待命节点的操作记录。这类数据由活动节点同步给日志节点,再由日志节点同步给待命节点。日志又至少有 3 态机器的集群保管,每个上放一个日志节点。
|
||||
记录节点本身状态的数据(比如节点有没有心跳)。这类数据存储在分布式应用协作引擎上,比如 ZooKeeper。
|
||||
|
||||
|
||||
有了这样的设计,当活动节点发生故障的时候,只需要迅速切换节点即可修复故障。
|
||||
|
||||
总结
|
||||
|
||||
这个模块我们对文件系统进行了系统的学习,下面我来总结一下文件系统的几块核心要点。
|
||||
|
||||
|
||||
理解虚拟文件系统的设计,理解在一个目录树结构当中,可以拥有不同的文件系统——一切皆文件的设计。基于这种结构,设备、磁盘、分布式文件系、网络请求都可以是文件。
|
||||
将空间分块管理是一种高效的常规手段。方便分配、方便回收、方便整理——除了文件系统,内存管理和分布式文件系统也会用到这种手段。
|
||||
日志文件系统的设计是重中之重,日志文件系统通过空间换时间,牺牲少量的读取性能,提升整体的写入效率。除了单机文件系统,这种设计在分布式文件系统和很多数据库当中也都存在。
|
||||
分层架构:将数据库系统、分布式文件系搭建在单机文件管理之上——知识是死的、思路是活的。希望你能将这部分知识运用到日常开发中,提升自己系统的性能。
|
||||
|
||||
|
||||
|
||||
|
||||
|
176
专栏/重学操作系统-完/32HDFS介绍:分布式文件系统是怎么回事?.md
Normal file
176
专栏/重学操作系统-完/32HDFS介绍:分布式文件系统是怎么回事?.md
Normal file
@@ -0,0 +1,176 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
32 HDFS 介绍:分布式文件系统是怎么回事?
|
||||
前面我们学习了单机文件系统、数据库索引的设计,这一讲我们讨论大数据环境下的数据管理——分布式文件系统和分布式数据库。分布式文件系统通过计算机网络连接大量物理节点,将不同机器、不同磁盘、不同逻辑分区的数据组织在一起,提供海量的数据存储(一般是 Petabytes 级别,1PB = 1024TB)。分布式数据库则在分布式文件系统基础上,提供应对具体场景的海量数据解决方案。
|
||||
|
||||
说起大数据,就不得不提历史上在存储领域影响深远的两篇 Paper。
|
||||
|
||||
|
||||
Google File System
|
||||
BigTable:A Distributed Storage System for Structured Data
|
||||
|
||||
|
||||
Google File System 是一个分布式文件系统,构成了今天大数据生态的底层存储,也是我们本讲主角 HDFS 的原型。HDFS(Hadoop Distributed File System)是 Google File System 的一个重要实现。
|
||||
|
||||
后者 BigTable 是一个分布式数据库。BigTable 本身是 Google 内部的项目,建立在 Google File System 之上,为 Google 的搜索引擎提供数据支撑。它是 2005 年公布的第一个版本,而且通过 Paper 公布了实现,在那个大数据还处于萌芽阶段的时代,BigTable 成为了启明星,今天我们常用的 HBase 还沿用着 BigTable 的设计。
|
||||
|
||||
因为两个重量级的 Paper 都是 Google 的产物,所以这一讲,我会结合搜索引擎的设计,带你走进分布式存储和数据库的世界。
|
||||
|
||||
存储所有的网页
|
||||
|
||||
作为搜索引擎最核心的一个能力,就是要存储所有的网页。目前世界上有 20 多亿个网站,每个网站还有大量的页面。搜索引擎不单单要存下这些页面,而且搜索引擎还需要存储这些网页的历史版本。
|
||||
|
||||
这里请你思考下,网站所有页面加起来有多大?举个例子,豆瓣所有页面加起来会有多大?如果把所有的变更都算上,比如一张页面经过 200 次迭代,就存 200 份,那么需要多少空间?Google 要把这些数据都存储下来,肯定是 PB 级别的数据。而且这个庞大的数据还需要提供给 Google 内部的分布式计算引擎等去计算,为网站打分、为用户生成索引,如果没有强大的存储能力是做不到的。
|
||||
|
||||
模型的选择
|
||||
|
||||
我们先来思考应该用何种模型存下这个巨大的网页表。
|
||||
|
||||
网页的历史版本,可以用 URL+ 时间戳进行描述。但是为了检索方便,网页不仅有内容,还有语言、外链等。在存储端可以先不考虑提供复杂的索引,比如说提供全文搜索。但是我们至少应该提供合理的数据读写方式。
|
||||
|
||||
网页除了内容,还有外链,外链就是链接到网页的外部网站。链接到一个网站的外链越多,那就说明这个网站在互联网中扮演的角色越重要。Google 创立之初就在基于外链的质量和数量为网站打分。外链可能是文字链接、图片链接等,因此外链也可以有版本,比如外链文本调整了,图片换了。除了外链还有标题、Logo,也需要存储。其实要存储的内容有很多,我不一一指出了。
|
||||
|
||||
我们先看看行存储,可不可以满足需求。比如每个网页( URL) 的数据是一行。 看似这个方案可行,可惜列不是固定。比如外链可能有很多个,如下表:
|
||||
|
||||
|
||||
|
||||
列不固定,不仅仅是行的大小不好确定,而是表格画不出来。何况每一列内容还可能有很多版本,不同版本是搜索引擎的爬虫在不同时间收录的内容,再加上内容本身也很大,有可能一个磁盘 Block 都存不下。看来行存储困难重重。
|
||||
|
||||
那么列存储行不行呢? 当然不行,我们都不确定到底有多少列? 有的网站有几千个外链,有的一个都没有,外链到底用多少列呢?
|
||||
|
||||
所以上表只可以作为我们存储设计的一个逻辑概念——这种逻辑概念在设计系统的时候,还有一个名词,叫作领域语言。领域语言是我们的思考方式,从搜索引擎的职责上讲,数据需要按照上面的结构聚合。况且根据 URL 拿数据,这是必须提供的能力。但是底层如何持久化,还需要进一步思考。
|
||||
|
||||
因为列是不确定的,这种情况下只能考虑用 Key-Value 结构,也就是 Map 存储。Map 是一种抽象的数据结构,本质是 Key-Value 数据的集合。 作为搜索引擎的支撑,Key 可以考虑设计为 的三元组,值就是对应版本的数据。
|
||||
|
||||
列名(Column)可以考虑分成两段,用:分隔开。列名包括列家族(Family) 、列标识(Qualifier)。这样设计是因为有时候多个列描述的是相似的数据,比如说外链(Anchor),就是一个列家族。然后百度、搜狐是外链家族的具体的标识(Qualifier)。比如来自百度页面 a 外链的列名是anchor:baidu.com/a。分成家族还有一个好处就是权限控制,比如不同部门的内部人员可以访问不同列家族的数据。当然有的列家族可能只有一个列,比如网页语言;有的列家族可能有很多列,比如外链。
|
||||
|
||||
接下来,我们思考:这个巨大的 Map(Key-Value)的集合应该用什么数据结构呢?——数组?链表?树?哈希表?
|
||||
|
||||
小提示:Map 只是 Key-Value 的集合。并没有约定具体如何实现,比如 HashMap 就是用哈希表实现 Map,ArrayMap 就是用数组实现 Map。LinkedMap 就是用链表实现 Map。LinkedJumpMap 就是用跳表实现 Map……
|
||||
|
||||
考虑到一行的数据并不会太大,我们可以用 URL 作为行的索引。当用户想用 Key 查找 Value 时,先使用 Key 中 URL 帮用户找到完整的行。这里可以考虑使用上一讲学习的 B+ 树去存储所有的 URL,建立一个 URL 到行号的索引。你看看,知识总是被重复利用,再次证明了人类的本质是复读机,其实就是学好基础很重要。通过 B+ 树,这样即便真的有海量的数据,也可以在少数几次、几十次查询内完成找到 URL 对应的数据。况且,我们还可以设计缓存。
|
||||
|
||||
B+ 树需要一种顺序,比较好的做法是 URL 以按照字典序排列。这是因为,相同域名的网页资源同时被用到的概率更高,应该安排从物理上更近,尽量把相同域名的数据放到相邻的存储块中(节省磁盘操作)。
|
||||
|
||||
那么行内的数据应该如何存储呢?可以考虑分列存储。那么行内用什么数据结构呢?如果列非常多,也可以考虑继续用 B+ 树。还有一种设计思路,是先把大表按照行拆分,比如若干行形成一个小片称作 Tablet,然后 Tablet 内部再使用列存储,这个设计我们会在后面一点讨论。
|
||||
|
||||
查询和写入
|
||||
|
||||
当客户端查询的时候,请求参数中会包含 ,这个时候我们可以通过 B+ 树定位到具体的行(也就是 URL 对应的数据)所在的块,再根据列名找到具体的列。然后,将一列数据导入到内存中,最后在内存中找到对应版本的数据。
|
||||
|
||||
客户端写入时,也是按照行→列的顺序,先找到列,再在这一列最后面追加数据。
|
||||
|
||||
对于修改、删除操作可以考虑不支持,因为所有的变更已经记录下来了。
|
||||
|
||||
分片(Tablet)的抽象
|
||||
|
||||
上面我们提到了可以把若干行组合在一起存储的设计。这个设计比较适合数据在集群中分布。假设存储网页的表有几十个 PB,那么先水平分表,就是通过 行(URL) 分表。URL 按照字典排序,相邻的 URL 数据从物理上也会相近。水平分表的结果,字典序相近的行(URL)数据会形成分片(Tablet),Tablet 这个单词类似药片的含义。
|
||||
|
||||
|
||||
|
||||
如上图所示:每个分片中含有一部分的行,视情况而定。分片(Tablet),可以作为数据分布的最小单位。分片内部可以考虑图上的行存储,也可以考虑内部是一个 B+ 树组织的列存储。
|
||||
|
||||
为了实现分布式存储,每个分片可以对应一个分布式文件系统中的文件。假设这个分布式文件系统接入了 Linux 的虚拟文件系统,使用和操作会同 Linux 本地文件并无二致。其实不一定会这样实现,这只是一个可行的方案。
|
||||
|
||||
为了存储安全,一个分片最少应该有 2 个副本,也就是 3 份数据。3 份数据在其中一份数据不一致后,可以对比其他两份的结果修正数据。这 3 份数据,我们不考虑跨数据中心。因为跨地域成本太高,吞吐量不好保证,假设它们还在同一地域的机房内,只不过在不同的机器、磁盘上。
|
||||
|
||||
|
||||
|
||||
块(Chunk)的抽象
|
||||
|
||||
比分片更小的单位是块(Chunk),这个单词和磁盘的块(Block)区分开。Chunk 是一个比 Block 更大的单位。Google File System 把数据分成了一个个 Chunk,然后每个 Chunk 会对应具体的磁盘块(Block)。
|
||||
|
||||
如下图,Table 是最顶层的结构,它里面含有许多分片(Tablets)。从数据库层面来看,每个分片是一个文件。数据库引擎维护到这个层面即可,至于这个文件如何在分布式系统中工作,就交给底层的文件系统——比如 Google File System 或者 Hadoop Distributed File System。
|
||||
|
||||
|
||||
|
||||
分布式文件系统通常会在磁盘的 Block 上再抽象一层 Chunk。一个 Chunk 通常比 Block 大很多,比如 Google File System 是 64KB,而通常磁盘的 Block 大小是 4K;HDFS 则是 128MB。这样的设计是为了减少 I/O 操作的频率,分块太小 I/O 频率就会上升,分块大 I/O 频率就减小。 比如一个 Google 的爬虫积攒了足够多的数据再提交到 GFS 中,就比爬虫频繁提交节省网络资源。
|
||||
|
||||
分布式文件的管理
|
||||
|
||||
接下来,我们来讨论一个完整的分布式系统设计。和单机文件系统一样,一个文件必须知道自己的数据(Chunk)存放在哪里。下图展示了一种最简单的设计,文件中包含了许多 Chunk 的 ID,然后每个 ChunkID 可以从 Chunk 的元数据中找到 Chunk 对应的位置。
|
||||
|
||||
|
||||
|
||||
如果 Chunk 比较大,比如说 HDFS 中 Chunk 有 128MB,那么 1PB 的数据需要 8,388,608 个条目。如果每个条目用 64bit 描述,也就是 8 个字节,只需要 64M 就可以描述清楚。考虑到一个 Chunk 必然会有冗余存储,也就是多个位置,实际会比 64M 多几倍,但也不会非常大了。
|
||||
|
||||
因此像 HDFS 和 GFS 等,为了简化设计会把所有文件目录结构信息,加上 Chunk 的信息,保存在一个单点上,通常称为 Master 节点。
|
||||
|
||||
|
||||
|
||||
下图中,客户端想要读取/foo/bar中某个 Chunk 中某段内容(Byterange)的数据,会分成 4 个步骤:
|
||||
|
||||
|
||||
客户端向 Master 发送请求,将想访问的文B件名、Chunk 的序号(可以通过 Chunk 大小和内容位置计算);
|
||||
Master 响应请求,返回 Chunk 的地址和 Chunk 的句柄(ID);
|
||||
客户端向 Chunk 所在的地址(一台 ChunkServer)发送请求,并将句柄(ID)和内容范围(Byterange)作为参数;
|
||||
ChunkServer 将数据返回给客户端。
|
||||
|
||||
|
||||
|
||||
|
||||
在上面这个模型中,有 3 个实体。
|
||||
|
||||
|
||||
客户端(Client)或者应用(Application),它们是数据的实际使用方,比如说 BigTable 数据库是 GFS 的 Client。
|
||||
Master 节点,它存储了所有的文件信息、Chunk 信息,权限信息等。
|
||||
ChunkServer 节点,它存储了实际的 Chunk 数据。
|
||||
|
||||
|
||||
Master 只有一台,ChunkServer 可以有很多台。上图中的 namespace 其实就是文件全名(含路径)的集合。Chunk 的 namespace 存储的是含文件全名 + ChunkLocation + ChunkID 的组合。文件的命名空间、Chunk 的命名空间,再加上文件和 Chunk 的对应关系,因为需要频繁使用,可以把它们全部都放到 Master 节点的内存中,并且利用 B 树等在内存中创建索引结构。ChunkServer 会和 Master 保持频繁的联系,将自己的变更告知 Master。这样就构成了一个完整的过程。
|
||||
|
||||
读和写
|
||||
|
||||
读取文件的过程需要两次往返(Round Trip),第一次是客户端和 Master 节点,第二次是客户端和某个 ChunkServer。
|
||||
|
||||
写入某个 Chunk 的时候,因为所有存储了这个 Chunk 的服务器都需要更新,所以需要将数据推送给所有的 ChunkServer。这里 GFS 设计中使用了一个非常巧妙的方案,先由客户端将数据推送给所有 ChunkServer 并缓存,而不马上更新。直到所有 ChunkServer 都收到数据后,再集中更新。这样的做法减少了数据不一致的时间。
|
||||
|
||||
下图是具体的更新步骤:
|
||||
|
||||
|
||||
客户端要和服务器签订租约,得到一个租期(Lease)。其实就是 Chunk 和 Chunk 所有复制品的修改权限。如果一个客户端拿到租期,在租期内,其他客户端能不能修改这个 Chunk。
|
||||
Master 告诉客户端该 Chunk 所有的节点位置。包括 1 台主节点(Primary)和普通节点(Secondary)。当然主节点和普通节点,都是 ChunkServer。主 ChunkServer 的作用是协助更新所有从 ChunkServer 的数据。
|
||||
这一步是设计得最巧妙的地方。客户端接下来将要写入的数据同时推送给所有关联的 ChunkServer。这些 ChunkServer 不会更新数据,而是把数据先缓存起来。
|
||||
图中的所有 ChunkServer 都收到了数据,并且给客户端回复后,客户端向主 ChunkServer 请求写入。
|
||||
主 ChunkServer 通知其他节点写入数据。因为数据已经推送过来了,所以这一步很快完成。
|
||||
写入完数据的节点,所有节点给主 ChunkServer 回复。
|
||||
主 ChunkServer 通知客户端成功。
|
||||
|
||||
|
||||
|
||||
|
||||
以上,就是 GFS 的写入过程。这里有个规律,实现强一致性(所有时刻、所有客户端读取到的数据是一致的)就需要停下所有节点的工作牺牲可用性;或者牺牲分区容错性,减少节点。GFS 和 HDFS 的设计,牺牲的是一致性本身,允许数据在一定时间范围内是不一致的,从而提高吞吐量。
|
||||
|
||||
容灾
|
||||
|
||||
在 HDFS 设计中,Master 节点也被称为 NameNode,用于存储命名空间数据。ChunkServer 也被称为 DataNode,用来存储文件数据。在 HDFS 的设计中,还有一个特殊的节点叫作辅助节点(Secondary Node)。辅助节点本身更像一个客户端,它不断和 NameNode 交流,并把 NameNode 最近的变更写成日志,存放到 DataNode 中。类似日志文件系统,每过一段时间,在 HDFS 中这些日志会形成一个还原点文件,这个机制和上一讲我们提到的日志文件系统类似。如果 Master 节点发生了故障,就可以通过这些还原点进行还原。
|
||||
|
||||
其他
|
||||
|
||||
在分布式文件系统和分布式数据库的设计中,还有很多有趣的知识,比如缓存的设计、空间的回收。如果你感兴趣,你可以进一步阅读我开篇给出的两篇论文。
|
||||
|
||||
|
||||
Google File System
|
||||
BigTable:A Distributed Storage System for Structured Data
|
||||
|
||||
|
||||
总结
|
||||
|
||||
现在,我们已经可以把所有的场景都串联起来。Google 需要的是一个分布式数据库,存储的数据是包括内容、外链、Logo、标题等在内的网页的全部版本和描述信息。为了描述这些信息,一台机器磁盘不够大,吞吐量也不够大。因此 Google 需要将数据分布存储,将这个大表(BigTable)拆分成很多小片(Tablet)。当然,这并不是直接面向用户的架构。给几十亿用户提供高效查询,还需要分布式计算,计算出给用户使用的内容索引。
|
||||
|
||||
Google 团队发现将数据分布出去是一个通用需求。不仅仅是 BigTable 数据库需要,很多其他数据库也可以在这个基础上构造。按照软件设计的原则,每个工具应该尽可能的专注和简单, Google 的架构师意识到需要一个底层的文件系统,就是 Google File System。这样,BigTable 使用 Tablet 的时候,只需要当成文件在使用,具体的分布式读写,就交给了 GFS。
|
||||
|
||||
后来,Hadoop 根据 GFS 设计了 Hadoop 分布式文件系统,用于处理大数据,仍然延续了整个 GFS 的设计。
|
||||
|
||||
以上,是一个完整的,分布式数据库、分布式存储技术的一个入门级探讨。
|
||||
|
||||
那么通过这节课的学习,你现在可以尝试来回答本节关联的面试题目:分布式文件系统是怎么回事?
|
||||
|
||||
【解析】分布式文件系统通过网络将不同的机器、磁盘、逻辑分区等存储资源利用起来,提供跨平台、跨机器的文件管理。通过这种方式,我们可以把很多相对廉价的服务器组合起来形成巨大的存储力量。
|
||||
|
||||
|
||||
|
||||
|
110
专栏/重学操作系统-完/33互联网协议群(TCPIP):多路复用是怎么回事?.md
Normal file
110
专栏/重学操作系统-完/33互联网协议群(TCPIP):多路复用是怎么回事?.md
Normal file
@@ -0,0 +1,110 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
33 互联网协议群(TCPIP):多路复用是怎么回事?
|
||||
这一讲我们开始学习《计算机网络》相关的知识。你可以把《计算机组成原理》《操作系统》和《计算机网络》看作补充计算机基础知识的 3 门必修课程。
|
||||
|
||||
|
||||
《计算机组成原理》讲述的是如何去理解程序和计算。
|
||||
《操作系统》讲述的是如何去理解和架构应用程序。
|
||||
《计算机网络》讲述的是如何去理解今天的互联网。
|
||||
|
||||
|
||||
本模块讲解的计网知识,以科普为主,我会用通俗的比喻、简单明了的语言,帮你在短时间内构建起网络的基本概念。如果要深入学习计算机网络的原理、算法,可以关注我即将在拉勾教育推出的《计算机网络》专栏。
|
||||
|
||||
现在来看,“计算机网络”也许是一个过时的词汇,它讲的是怎么用计算实现通信。今天我们已经发展到了一个互联网、物联网的时代,社交网络、云的时代,再来看网络,意义已经发生转变。但这里面还是有很多经典的知识依旧在传承。比如说 TCP/IP 协议,问世后就逐渐成为占有统治地位的通信协议。虽然后面诞生出了许许多多的协议,但是我们仍然习惯性地把整个互联网的架构称为 TCP/IP 协议群,也叫作互联网协议群(Internet Protocol Suit)。
|
||||
|
||||
协议的分层
|
||||
|
||||
对于多数的应用和用户而言,使用互联网的一个基本要求就是数据可以无损地到达。用户通过应用进行网络通信,应用启动之后就变成了进程。因此,所有网络通信的本质目标就是进程间通信。世界上有很多进程需要通信,我们要找到一种通用的,每个进程都能认可和接受的通信方式,这就是协议。
|
||||
|
||||
应用层
|
||||
|
||||
从分层架构上看,应用工作在应用层(Application Layer)。应用的功能,都在应用层实现。所以应用层很好理解,说的就是应用本身。当两个应用需要通信的时候,应用(进程中的线程)就调用传输层进行通信。从架构上说,应用层只专注于为用户提供价值即可,没有必要思考数据如何传输。而且应用的开发商和传输库的提供方也不是一个团队。
|
||||
|
||||
|
||||
|
||||
传输层
|
||||
|
||||
为应用层提供网络支持的,就是传输层(Transport Layer)。
|
||||
|
||||
传输层控制协议(Transmission Control Protocol)是目前世界上应用最广泛的传输层协议。传输层为应用提供通信能力。比如浏览器想访问服务器,浏览器程序就会调用传输层程序;Web 服务接收浏览器的请求,Web 服务程序就会调用传输层程序接收数据。
|
||||
|
||||
考虑到应用需要传输的数据可能会非常大,直接传输不好控制。传输层需要将数据切块,即使一个分块传丢了、损坏了,可以重新发一个分块,而不用重新发送整体。在 TCP 协议中,我们把每个分块称为一个 TCP 段(TCP Segment)。
|
||||
|
||||
|
||||
|
||||
传输层负责帮助应用传输数据给应用。考虑到一台主机上可能有很多个应用在传输数据,而一台服务器上可能有很多个应用在接收数据。因此,我们需要一个编号将应用区分开。这个编号就是端口号。比如 80 端口通常是 Web 服务器在使用;22 端口通常是远程登录服务在使用。而桌面浏览器,可能每个打开的标签栏都是一个独立的进程,每个标签栏都会使用临时分配的端口号。TCP 封包(TCP Segment)上携带了端口号,接收方可以识别出封包发送给哪个应用。
|
||||
|
||||
网络层
|
||||
|
||||
接下来你要思考的问题是:传输层到底负不负责将数据从一个设备传输到另一个设备(主机到主机,Host To Host)。仔细思考这个过程,你会发现如果这样设计,传输层就会违反简单、高效、专注的设计原则。
|
||||
|
||||
我们从一个主机到另一个主机传输数据的网络环境是非常复杂的。中间会通过各种各样的线路,有形形色色的交叉路口——有各式各样的路径和节点需要选择。核心的设计原则是,我们不希望一层协议处理太多的问题。传输层作为应用间数据传输的媒介,服务好应用即可。对应用层而言,传输层帮助实现应用到应用的通信。而实际的传输功能交给传输层的下一层,也就是网络层(Internet Layer) 会更好一些。
|
||||
|
||||
|
||||
|
||||
IP 协议(Internet Protocol)是目前起到统治地位的网络层协议。IP 协议会将传输层的封包再次切分,得到 IP 封包。网络层负责实际将数据从一台主机传输到另一台主机(Host To Host),因此网络层需要区分主机的编号。
|
||||
|
||||
在互联网上,我们用 IP 地址给主机进行编号。例如 IPv4 协议,将地址总共分成了四段,每段是 8 位,加起来是 32 位。寻找地址的过程类似我们从国家、城市、省份一直找到区县。当然还有特例,比如有的城市是直辖市,有的省份是一个特别行政区。而且国与国体制还不同,像美国这样的国家,一个州其实可以相当于一个国家。
|
||||
|
||||
IP 协议里也有这个问题,类似行政区域划分,IP 协议中具体如何划分子网,需要配合子网掩码才能够明确。每一级网络都需要一个子网掩码,来定义网络子网的性质,相当于告诉物流公司到这一级网络该如何寻找目标地址,也就是寻址(Addressing)。关于更多子网掩码如何工作,及更多原理类的知识我会在拉勾教育的《计算机网络》专栏中和你分享。
|
||||
|
||||
除了寻址(Addressing),IP 协议还有一个非常重要的能力就是路由。在实际传输过程当中,数据并不是从主机直接就传输到了主机。而是会经过网关、基站、防火墙、路由器、交换机、代理服务器等众多的设备。而网络的路径,也称作链路,和现实生活中道路非常相似,会有岔路口、转盘、高速路、立交桥等。
|
||||
|
||||
因此,当封包到达一个节点,需要通过算法决定下一步走哪条路径。我们在现实生活中经常会碰到多条路径都可以到达同一个目的地的情况,在网络中也是如此。总结一下。寻址告诉我们去往下一个目的地该朝哪个方向走,路由则是根据下一个目的地选择路径。寻址更像在导航,路由更像在操作方向盘。
|
||||
|
||||
数据链路层(Data Link Layer)
|
||||
|
||||
考虑到现实的情况,网络并不是一个完整的统一体。比如一个基站覆盖的周边就会形成一个网络。一个家庭的所有设备,一个公司的所有设备也会形成一个网络。所以在现实的情况中,数据在网络中设备间或者跨网络进行传输。而数据一旦需要跨网络传输,就需要有一个设备同时在两个网络当中。通过路由,我们知道了下一个要去的 IP 地址,可是当前的网络中哪个设备对应这个 IP 地址呢?
|
||||
|
||||
为了解决这个问题,我们需要有一个专门的层去识别网络中的设备,让数据在一个链路(网络中的路径)中传递,这就是数据链路层(Data Link Layer)。数据链路层为网络层提供链路级别传输的支持。
|
||||
|
||||
物理层
|
||||
|
||||
当数据在实际的设备间传递时,可能会用电线、电缆、光纤、卫星、无线等各种通信手段。因此,还需要一层将光电信号、设备差异封装起来,为数据链路层提供二进制传输的服务。这就是物理层(Physical Layer)。
|
||||
|
||||
因此,从下图中你可以看到,由上到下,互联网协议可以分成五层,分别是应用层、传输层、网络层、数据链路层和物理层。
|
||||
|
||||
|
||||
|
||||
多路复用
|
||||
|
||||
在上述的分层模型当中,一台机器上的应用可以有很多。但是实际的出口设备,比如说网卡、网线通常只有一份。因此这里需要用到一个叫作多路复用(Multiplex)的技术。多路复用,就是多个信号,复用一个信道。
|
||||
|
||||
传输层多路复用
|
||||
|
||||
对应用而言,应用层抽象应用之间通信的模型——比如说请求返回模型。一个应用可能会同时向服务器发送多个请求。因为建立一个连接也是需要开销的,所以可以多个请求复用一个 TCP 连接。复用连接一方面可以节省流量,另一方面能够降低延迟。如果应用串行地向服务端发送请求,那么假设第一个请求体积较大,或者第一个请求发生了故障,就会阻塞后面的请求。
|
||||
|
||||
而使用多路复用技术,如下图所示,多个请求相当于并行的发送请求。即使其中某个请求发生故障,也不会阻塞其他请求。从这个角度看,多路复用实际上是一种 Non-Blocking(非阻塞)的技术。我们再来看下面这张图,不同的请求被传输层切片,我用不同的颜色区分出来,如果其中一个数据段(TCP Segment)发生异常,只影响其中一个颜色的请求,其他请求仍然可以到达服务。
|
||||
|
||||
|
||||
|
||||
网络层多路复用
|
||||
|
||||
传输层是一个虚拟的概念,但是网络层是实实在在的。两个应用之间的传输,可以建立无穷多个传输层连接,前提是你的资源足够。但是两个应用之间的线路、设备,需要跨越的网络往往是固定的。在我们的互联网上,每时每刻都有大量的应用在互发消息。而这些应用要复用同样的基础建设——网线、路由器、网关、基站等。
|
||||
|
||||
网络层没有连接这个概念。你可以把网络层理解成是一个巨大的物流公司。不断从传输层接收数据,然后进行打包,每一个包是一个 IP 封包。然后这个物流公司,负责 IP 封包的收发。所以,是很多很多的传输层在共用底下同一个网络层,这就是网络层的多路复用。
|
||||
|
||||
总结一下。应用层的多路复用,如多个请求使用同一个信道并行的传输,实际上是传输层提供的多路复用能力。传输层的多路复用,比如多个 TCP 连接复用一条线路,实际上是网络层在提供多路复用能力。你可以把网络层想象成一个不断收发包裹的机器,在网络层中并没有连接这个概念,所以网络层天然就是支持多路复用的。
|
||||
|
||||
多路复用的意义
|
||||
|
||||
在工作当中,我们经常会使用到多路复用的能力。多路复用让多个信号(例如:请求/返回等)共用一个信道(例如:一个 TCP 连接),那么在这个信道上,信息密度就会增加。在密度增加的同时,通过并行发送信号的方式,可以减少阻塞。比如说应用层的 HTTP 协议,浏览器打开的时候就会往服务器发送很多个请求,多个请求混合在一起,复用相同连接,数据紧密且互相隔离(不互相阻塞)。同理,服务之间的远程调用、消息队列,这些也经常需要多路复用。
|
||||
|
||||
总结
|
||||
|
||||
那么通过这一讲的学习,你现在可以尝试来回答本节关联的面试题目:多路复用是怎么回事?
|
||||
|
||||
【解析】多路复用让多个信号(例如:请求/返回等)共用一个信道(例如:一个 TCP 连接)。它有两个明显的优势。
|
||||
|
||||
|
||||
提升吞吐量。多一个信号被紧密编排在一起(例如:TCP 多路复用节省了多次连接的数据),这样网络不容易空载。
|
||||
多个信号间隔离。信号间并行传输,并且隔离,不会互相影响。
|
||||
|
||||
|
||||
|
||||
|
||||
|
129
专栏/重学操作系统-完/34UDP协议:UDP和TCP相比快在哪里?.md
Normal file
129
专栏/重学操作系统-完/34UDP协议:UDP和TCP相比快在哪里?.md
Normal file
@@ -0,0 +1,129 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
34 UDP 协议:UDP 和 TCP 相比快在哪里?
|
||||
TCP 和 UDP 是目前使用最广泛的两个传输层协议,同时也是面试考察的重点内容。今天我会初步带你认识这两个协议,一起探寻它们之间最大的区别。
|
||||
|
||||
在开始本讲的重点内容前,我们先来说说 RFC 文档(Request For Comments,请求评论),互联网的很多基础建设都是以 RFC 的形式文档化,它给用户提供了阅读和学习的权限。在给大家准备《计算机网络》专栏的时候,我也经常查阅 RFC 文档。
|
||||
|
||||
如果你查阅 TCP 和 UDP 的 RFC 文档,会发现一件非常有趣的事情。TCP 协议的 RFC 很长,我足足读了好几天才把它们全部弄明白。UDP 的 RFC 非常短,只有短短的两页,一个小时就能读明白。这让我不禁感叹,如果能穿越到当时那个年代,我就去发明 UDP 协议,因为实在是太简单了。但即使是这个简单协议,也同样主宰着计算机网络协议的半壁江山。
|
||||
|
||||
那么这一讲我们就以 TCP 和 UDP 的区别为引,带你了解这两个在工作中使用频率极高、极为重要的传输层协议。
|
||||
|
||||
可靠性
|
||||
|
||||
首先我们比较一下这两个协议在可靠性(Reliablility)上的区别。如果一个网络协议是可靠的,那么它能够保证数据被无损地传送到目的地。当应用的设计者选择一个具有可靠性的协议时,通常意味着这个应用不能容忍数据在传输过程中被损坏。
|
||||
|
||||
如果你是初学者,可能会认为所有的应用都需要可靠性。其实不然,比如说一个视频直播服务。如果在传输过程当中,视频图像发生了一定的损坏,用户看到的只是某几个像素、颜色不准确了,或者某几帧视频丢失了——这对用户来说是可以容忍的。但在观看视频的时候,用户最怕的不是实时数据发生一定的损坏,而是吞吐量得不到保证。比如视频看到一半卡住了,要等很久,或者丢失了一大段视频数据,导致错过精彩的内容。
|
||||
|
||||
TCP 协议,是一个支持可靠性的协议。UDP 协议,是一个不支持可靠性的协议。接下来我们讨论几个常见实现可靠性的手段。
|
||||
|
||||
校验和(Checksum)
|
||||
|
||||
首先我们来说说校验和。这是一种非常常见的可靠性检查手段。
|
||||
|
||||
尽管 UDP 不支持可靠性,但是像校验和(Checksum)这一类最基本的数据校验,它还是支持的。不支持可靠性,并不意味着完全放弃可靠性。TCP 和 UDP 都支持最基本的校验和算法。
|
||||
|
||||
下面我为你举例一种最简单的校验和算法:纵向冗余检查。伪代码如下:
|
||||
|
||||
byte c = 0;
|
||||
|
||||
for(byte x in bytes) {
|
||||
|
||||
c = c xor x;
|
||||
|
||||
}
|
||||
|
||||
|
||||
xor是异或运算。上面的程序在计算字节数组 bytes 的校验和。c是最终的结果。你可以看到将所有bytes两两异或,最终的结果就是校验和。假设我们要传输 bytes,如果在传输过程中bytes发生了变化,校验和有很大概率也会跟着变化。当然也可能存在bytes发生变化,校验和没有变化的特例,不过校验和可以很大程度上帮助我们识别数据是否损坏了。
|
||||
|
||||
|
||||
|
||||
当要传输数据的时候,数据会被分片,我们把每个分片看作一个字节数组。然后在分片中,预留几个字节去存储校验和。校验和随着数据分片一起传输到目的地,目的地会用同样的算法再次计算校验和。如果二者校验和不一致,代表中途数据发生了损坏。
|
||||
|
||||
对于 TCP 和 UDP,都实现了校验和算法,但二者的区别是,TCP 如果发现校验核对不上,也就是数据损坏,会主动丢失这个封包并且重发。而 UDP 什么都不会处理,UDP 把处理的权利交给使用它的程序员。
|
||||
|
||||
请求/应答/连接模型
|
||||
|
||||
另一种保证可靠性的方法是请求响应和连接的模型。TCP 实现了请求、响应和连接的模型,UDP 没有实现这个模型。
|
||||
|
||||
在通信当中,我们可以把通信双方抽象成两个人用电话通信一样,需要先建立联系(保持连接)。发起会话的人是发送请求,对方需要应答(或者称为响应)。会话双方保持一个连接,直到双方说再见。
|
||||
|
||||
在 TCP 协议当中,任何一方向另一方发送信息,另一方都需要给予一个应答。如果发送方在一定的时间内没有获得应答,发送方就会认为自己的信息没有到达目的地,中途发生了损坏或者丢失等,因此发送方会选择重发这条消息。
|
||||
|
||||
这样一个模式也造成了 TCP 协议的三次握手和四次挥手,下面我们一起来具体分析一下。
|
||||
|
||||
1. TCP 的三次握手
|
||||
|
||||
在 TCP 协议当中。我们假设 Alice 和 Bob 是两个通信进程。当 Alice 想要和 Bob 建立连接的时候,Alice 需要发送一个请求建立连接的消息给 Bob。这种请求建立连接的消息在 TCP 协议中称为同步(Synchronization, SYN)。而 Bob 收到 SYN,必须马上给 Alice 一个响应。这个响应在 TCP 协议当中称为响应(Acknowledgement,ACK)。请你务必记住这两个单词。不仅是 TCP 在用,其他协议也会复用这样的概念,来描述相同的事情。
|
||||
|
||||
当 Alice 给 Bob SYN,Bob 给 Alice ACK,这个时候,对 Alice 而言,连接就建立成功了。但是 TCP 是一个双工协议。所谓双工协议,代表数据可以双向传送。虽然对 Alice 而言,连接建立成功了。但是对 Bob 而言,连接还没有建立。为什么这么说呢?你可以这样思考,如果这个时候,Bob 马上给 Alice 发送信息,信息可能先于 Bob 的 ACK 到达 Alice,但这个时候 Alice 还不知道连接建立成功。 所以解决的办法就是 Bob 再给 Alice 发一次 SYN ,Alice 再给 Bob 一个 ACK。以上就是 TCP 的三次握手内容。
|
||||
|
||||
你可能会问,这明明是四次握手,哪里是三次握手呢?这是因为,Bob 给 Alice 的 ACK ,可以和 Bob 向 Alice 发起的 SYN 合并,称为一条 SYN-ACK 消息。TCP 协议以此来减少握手的次数,减少数据的传输,于是 TCP 就变成了三次握手。下图中绿色标签状是 Alice 和 Bob 的状态,完整的 TCP 三次握手的过程如下图所示:
|
||||
|
||||
|
||||
|
||||
2. TCP 的四次挥手
|
||||
|
||||
四次挥手(TCP 断开连接)的原理类似。中断连接的请求我们称为 Finish(用 FIN 表示);和三次握手过程一样,需要分析成 4 步:
|
||||
|
||||
|
||||
第 1 步是 Alice 发送 FIN
|
||||
第 2 步是 Bob 给 ACK
|
||||
第 3 步是 Bob 发送 FIN
|
||||
第 4 步是 Alice 给 ACK
|
||||
|
||||
|
||||
之所以是四次挥手,是因为第 2 步和 第 3 步在挥手的过程中不能合并为 FIN-ACK。原因是在挥手的过程中,Alice 和 Bob 都可能有未完成的工作。比如对 Bob 而言,可能还存在之前发给 Alice 但是还没有收到 ACK 的请求。因此,Bob 收到 Alice 的 FIN 后,就马上给 ACK。但是 Bob 会在自己准备妥当后,再发送 FIN 给 Alice。完整的过程如下图所示:
|
||||
|
||||
|
||||
|
||||
3. 连接
|
||||
|
||||
连接是一个虚拟概念,连接的目的是让连接的双方达成默契,倾尽资源,给对方最快的响应。经历了三次握手,Alice 和 Bob 之间就建立了连接。连接也是一个很好的编程模型。当连接不稳定的时候,可以中断连接后再重新连接。这种模式极大地增加了两个应用之间的数据传输的可靠性。
|
||||
|
||||
以上就是 TCP 中存在的,而 UDP 中没有的机制,你可以仔细琢磨琢磨。
|
||||
|
||||
封包排序
|
||||
|
||||
可靠性有一个最基本的要求是数据有序发出、无序传输,并且有序组合。TCP 协议保证了这种可靠性,UDP 则没有保证。
|
||||
|
||||
在传输之前,数据被拆分成分块。在 TCP 中叫作一个TCP Segment。在 UDP 中叫作一个UDP Datagram。Datagram 单词的含义是数据传输的最小单位。在到达目的地之后,尽管所有的数据分块可能是乱序到达的,但为了保证可靠性,乱序到达的数据又需要被重新排序,恢复到原有数据的顺序。
|
||||
|
||||
在这个过程当中,TCP 利用了滑动窗口、快速重传等算法,保证了数据的顺序。而 UDP,仅仅是为每个 Datagram 标注了序号,并没有帮助应用程序进行数据的排序,这也是 TCP 和 UDP 在保证可靠性上一个非常重要的区别。
|
||||
|
||||
使用场景
|
||||
|
||||
上面的内容中,我们比较了 TCP 和 UDP 在可靠性上的区别,接下来我们看看两个协议的使用场景。
|
||||
|
||||
我们先来看一道面试题:如果客户端和服务器之间的单程平均延迟是 30 毫秒,那么客户端 Ping 服务端需要多少毫秒?
|
||||
|
||||
【分析】这个问题最核心的点是需要思考 Ping 服务应该由 TCP 实现还是 UDP 实现?请你思考:Ping 需不需要保持连接呢?答案是不需要,Ping 服务器的时候把数据发送过去即可,并不需要特地建立一个连接。
|
||||
|
||||
请你再思考,Ping 需不需要保证可靠性呢?答案依然是不需要,如果发生了丢包, Ping 将丢包计入丢包率即可。所以从这个角度来看,Ping 使用 UDP 即可。
|
||||
|
||||
所以这道面试题应该是 Round Trip 最快需要在 60 毫秒左右。一个来回的时间,我们也通常称为 Round Trip 时间。
|
||||
|
||||
通过分析上面的例子,我想告诉你,TCP 和 UDP 的使用场景是不同的。TCP 适用于需要可靠性,需要连接的场景。UDP 因为足够简单,只对数据进行简单加工处理,就调用底层的网络层(IP 协议)传输数据去了。因此 UDP 更适合对可靠性要求不高的场景。
|
||||
|
||||
另外很多需要定制化的场景,非常需要 UDP。以 HTTP 协议为例,在早期的 HTTP 协议的设计当中就选择了 TCP 协议。因为在 HTTP 的设计当中,请求和返回都是需要可靠性的。但是随着 HTTP 协议的发展,到了 HTTP 3.0 的时候,就开始基于 UDP 进行传输。这是因为,在 HTTP 3.0 协议当中,在 UDP 之上有另一个QUIC 协议在负责可靠性。UDP 足够简单,在其上构建自己的协议就很方便。
|
||||
|
||||
你可以再思考一个问题:文件上传应该用 TCP 还是 UDP 呢?乍一看肯定是 TCP 协议,因为文件上传当然需要可靠性,防止数据损坏。但是如果你愿意在 UDP 上去实现一套专门上传文件的可靠性协议,性能是可以超越 TCP 协议的。因为你只需要解决文件上传一种需求,不用像 TCP 协议那样解决通用需求。
|
||||
|
||||
所以时至今日,到底什么情况应该用 TCP,什么情况用 UDP?这个问题边界的确在模糊化。总体来说,需要可靠性,且不希望花太多心思在网络协议的研发上,就使用 TCP 协议。
|
||||
|
||||
总结
|
||||
|
||||
最后我们再来总结一下,大而全的协议用起来舒服,比如 TCP;灵活的协议方便定制和扩展,比如 UDP。二者不分伯仲,各有千秋。
|
||||
|
||||
这一讲我们深入比较了 TCP 和 UDP 的可靠性及它们的使用场景。关于原理部分,比如具体 TCP 的滑动窗口算法、数据的切割算法、数据重传算法;TCP、UDP 的封包内部究竟有哪些字段,格式如何等。如果你感兴趣,可以来学习我将在拉勾教育推出的《计算机网络》专栏。
|
||||
|
||||
那么通过这一讲的学习,你现在可以尝试来回答本讲关联的面试题目:UDP 比 TCP 快在哪里?
|
||||
|
||||
【解析】使用 UDP 传输数据,不用建立连接,数据直接丢过去即可。至于接收方,有没有在监听?会不会接收?那就是接收方的事情了。UDP 甚至不考虑数据的可靠性。至于发送双方会不会基于 UDP 再去定制研发可靠性协议,那就是开发者的事情了。所以 UDP 快在哪里?UDP 快在它足够简单。因为足够简单,所以 UDP 对计算性能、对网络占用都是比 TCP 少的。
|
||||
|
||||
|
||||
|
||||
|
988
专栏/重学操作系统-完/35Linux的IO模式:selectpollepoll有什么区别?.md
Normal file
988
专栏/重学操作系统-完/35Linux的IO模式:selectpollepoll有什么区别?.md
Normal file
@@ -0,0 +1,988 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
35 Linux 的 IO 模式:selectpollepoll 有什么区别?
|
||||
我们总是想方设法地提升系统的性能。操作系统层面不能给予处理业务逻辑太多帮助,但对于 I/O 性能,操作系统可以通过底层的优化,帮助应用做到极致。
|
||||
|
||||
这一讲我将和你一起讨论 I/O 模型。为了引发你更多的思考,我将同步/异步、阻塞/非阻塞等概念滞后讲解。我们先回到一个最基本的问题:如果有一台服务器,需要响应大量的请求,操作系统如何去架构以适应这样高并发的诉求。
|
||||
|
||||
说到架构,就离不开操作系统提供给应用程序的系统调用。我们今天要介绍的 select/poll/epoll 刚好是操作系统提供给应用的三类处理 I/O 的系统调用。这三类系统调用有非常强的代表性,这一讲我会围绕它们,以及处理并发和 I/O 多路复用,为你讲解操作系统的 I/O 模型。
|
||||
|
||||
从网卡到操作系统
|
||||
|
||||
为了弄清楚高并发网络场景是如何处理的,我们先来看一个最基本的内容:当数据到达网卡之后,操作系统会做哪些事情?
|
||||
|
||||
网络数据到达网卡之后,首先需要把数据拷贝到内存。拷贝到内存的工作往往不需要消耗 CPU 资源,而是通过 DMA 模块直接进行内存映射。之所以这样做,是因为网卡没有大量的内存空间,只能做简单的缓冲,所以必须赶紧将它们保存下来。
|
||||
|
||||
Linux 中用一个双向链表作为缓冲区,你可以观察下图中的 Buffer,看上去像一个有很多个凹槽的线性结构,每个凹槽(节点)可以存储一个封包,这个封包可以从网络层看(IP 封包),也可以从传输层看(TCP 封包)。操作系统不断地从 Buffer 中取出数据,数据通过一个协议栈,你可以把它理解成很多个协议的集合。协议栈中数据封包找到对应的协议程序处理完之后,就会形成 Socket 文件。
|
||||
|
||||
!
|
||||
|
||||
如果高并发的请求量级实在太大,有可能把 Buffer 占满,此时,操作系统就会拒绝服务。网络上有一种著名的攻击叫作拒绝服务攻击,就是利用的这个原理。操作系统拒绝服务,实际上是一种保护策略。通过拒绝服务,避免系统内部应用因为并发量太大而雪崩。
|
||||
|
||||
如上图所示,传入网卡的数据被我称为 Frames。一个 Frame 是数据链路层的传输单位(或封包)。现代的网卡通常使用 DMA 技术,将 Frame 写入缓冲区(Buffer),然后在触发 CPU 中断交给操作系统处理。操作系统从缓冲区中不断取出 Frame,通过协进栈(具体的协议)进行还原。
|
||||
|
||||
在 UNIX 系的操作系统中,一个 Socket 文件内部类似一个双向的管道。因此,非常适用于进程间通信。在网络当中,本质上并没有发生变化。网络中的 Socket 一端连接 Buffer, 一端连接应用——也就是进程。网卡的数据会进入 Buffer,Buffer 经过协议栈的处理形成 Socket 结构。通过这样的设计,进程读取 Socket 文件,可以从 Buffer 中对应节点读走数据。
|
||||
|
||||
对于 TCP 协议,Socket 文件可以用源端口、目标端口、源 IP、目标 IP 进行区别。不同的 Socket 文件,对应着 Buffer 中的不同节点。进程们读取数据的时候从 Buffer 中读取,写入数据的时候向 Buffer 中写入。通过这样一种结构,无论是读和写,进程都可以快速定位到自己对应的节点。
|
||||
|
||||
以上就是我们对操作系统和网络接口交互的一个基本讨论。接下来,我们讨论一下作为一个编程模型的 Socket。
|
||||
|
||||
Socket 编程模型
|
||||
|
||||
通过前面讲述,我们知道 Socket 在操作系统中,有一个非常具体的从 Buffer 到文件的实现。但是对于进程而言,Socket 更多是一种编程的模型。接下来我们讨论作为编程模型的 Socket。
|
||||
|
||||
|
||||
|
||||
如上图所示,Socket 连接了应用和协议,如果应用层的程序想要传输数据,就创建一个 Socket。应用向 Socket 中写入数据,相当于将数据发送给了另一个应用。应用从 Socket 中读取数据,相当于接收另一个应用发送的数据。而具体的操作就是由 Socket 进行封装。具体来说,对于 UNIX 系的操作系统,是利用 Socket 文件系统,Socket 是一种特殊的文件——每个都是一个双向的管道。一端是应用,一端是缓冲区。
|
||||
|
||||
那么作为一个服务端的应用,如何知道有哪些 Socket 呢?也就是,哪些客户端连接过来了呢?这是就需要一种特殊类型的 Socket,也就是服务端 Socket 文件。
|
||||
|
||||
|
||||
|
||||
如上图所示,当有客户端连接服务端时,服务端 Socket 文件中会写入这个客户端 Socket 的文件描述符。进程可以通过 accept() 方法,从服务端 Socket 文件中读出客户端的 Socket 文件描述符,从而拿到客户端的 Socket 文件。
|
||||
|
||||
程序员实现一个网络服务器的时候,会先手动去创建一个服务端 Socket 文件。服务端的 Socket 文件依然会存在操作系统内核之中,并且会绑定到某个 IP 地址和端口上。以后凡是发送到这台机器、目标 IP 地址和端口号的连接请求,在形成了客户端 Socket 文件之后,文件的文件描述符都会被写入到服务端的 Socket 文件中。应用只要调用 accept 方法,就可以拿到这些客户端的 Socket 文件描述符,这样服务端的应用就可以方便地知道有哪些客户端连接了进来。
|
||||
|
||||
而每个客户端对这个应用而言,都是一个文件描述符。如果需要读取某个客户端的数据,就读取这个客户端对应的 Socket 文件。如果要向某个特定的客户端发送数据,就写入这个客户端的 Socket 文件。
|
||||
|
||||
以上就是 Socket 的编程模型。
|
||||
|
||||
I/O 多路复用
|
||||
|
||||
在上面的讨论当中,进程拿到了它关注的所有 Socket,也称作关注的集合(Intersting Set)。如下图所示,这种过程相当于进程从所有的 Socket 中,筛选出了自己关注的一个子集,但是这时还有一个问题没有解决:进程如何监听关注集合的状态变化,比如说在有数据进来,如何通知到这个进程?
|
||||
|
||||
|
||||
|
||||
其实更准确地说,一个线程需要处理所有关注的 Socket 产生的变化,或者说消息。实际上一个线程要处理很多个文件的 I/O。所有关注的 Socket 状态发生了变化,都由一个线程去处理,构成了 I/O 的多路复用问题。如下图所示:
|
||||
|
||||
|
||||
|
||||
处理 I/O 多路复用的问题,需要操作系统提供内核级别的支持。Linux 下有三种提供 I/O 多路复用的 API,分别是:
|
||||
|
||||
|
||||
select
|
||||
poll
|
||||
epoll
|
||||
|
||||
|
||||
如下图所示,内核了解网络的状态。因此不难知道具体发生了什么消息,比如内核知道某个 Socket 文件状态发生了变化。但是内核如何知道该把哪个消息给哪个进程呢?
|
||||
|
||||
|
||||
|
||||
一个 Socket 文件,可以由多个进程使用;而一个进程,也可以使用多个 Socket 文件。进程和 Socket 之间是多对多的关系。另一方面,一个 Socket 也会有不同的事件类型。因此操作系统很难判断,将哪样的事件给哪个进程。
|
||||
|
||||
这样在进程内部就需要一个数据结构来描述自己会关注哪些 Socket 文件的哪些事件(读、写、异常等)。通常有两种考虑方向,一种是利用线性结构,比如说数组、链表等,这类结构的查询需要遍历。每次内核产生一种消息,就遍历这个线性结构。看看这个消息是不是进程关注的?另一种是索引结构,内核发生了消息可以通过索引结构马上知道这个消息进程关不关注。
|
||||
|
||||
select()
|
||||
|
||||
select 和 poll 都采用线性结构,select 允许用户传入 3 个集合。如下面这段程序所示:
|
||||
|
||||
fd_set read_fd_set, write_fd_set, error_fd_set;
|
||||
|
||||
while(true) {
|
||||
|
||||
select(..., &read_fd_set, &write_fd_set, &error_fd_set);
|
||||
|
||||
}
|
||||
|
||||
|
||||
每次 select 操作会阻塞当前线程,在阻塞期间所有操作系统产生的每个消息,都会通过遍历的手段查看是否在 3 个集合当中。上面程序read_fd_set中放入的是当数据可以读取时进程关心的 Socket;write_fd_set是当数据可以写入时进程关心的 Socket;error_fd_set是当发生异常时进程关心的 Socket。
|
||||
|
||||
用户程序可以根据不同集合中是否有某个 Socket 判断发生的消息类型,程序如下所示:
|
||||
|
||||
fd_set read_fd_set, write_fd_set, error_fd_set;
|
||||
|
||||
while(true) {
|
||||
|
||||
select(..., &read_fd_set, &write_fd_set, &error_fd_set);
|
||||
|
||||
for (i = 0; i < FD_SETSIZE; ++i)
|
||||
|
||||
if (FD_ISSET (i, &read_fd_set)){
|
||||
|
||||
// Socket可以读取
|
||||
|
||||
} else if(FD_ISSET(i, &write_fd_set)) {
|
||||
|
||||
// Socket可以写入
|
||||
|
||||
} else if(FD_ISSET(i, &error_fd_set)) {
|
||||
|
||||
// Socket发生错误
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
上面程序中的 FD_SETSIZE 是一个系统的默认设置,通常是 1024。可以看出,select 模式能够一次处理的文件描述符是有上限的,也就是 FD_SETSIZE。当并发请求过多的时候, select 就无能为力了。但是对单台机器而言,1024 个并发已经是一个非常大的流量了。
|
||||
|
||||
接下来我给出一个完整的、用 select 实现的服务端程序供你参考,如下所示:
|
||||
|
||||
#include <stdio.h>
|
||||
|
||||
#include <errno.h>
|
||||
|
||||
#include <stdlib.h>
|
||||
|
||||
#include <unistd.h>
|
||||
|
||||
#include <sys/types.h>
|
||||
|
||||
#include <sys/Socket.h>
|
||||
|
||||
#include <netinet/in.h>
|
||||
|
||||
#include <netdb.h>
|
||||
|
||||
#define PORT 5555
|
||||
|
||||
#define MAXMSG 512
|
||||
|
||||
int
|
||||
|
||||
read_from_client (int filedes)
|
||||
|
||||
{
|
||||
|
||||
char buffer[MAXMSG];
|
||||
|
||||
int nbytes;
|
||||
|
||||
nbytes = read (filedes, buffer, MAXMSG);
|
||||
|
||||
if (nbytes < 0)
|
||||
|
||||
{
|
||||
|
||||
/* Read error. */
|
||||
|
||||
perror ("read");
|
||||
|
||||
exit (EXIT_FAILURE);
|
||||
|
||||
}
|
||||
|
||||
else if (nbytes == 0)
|
||||
|
||||
/* End-of-file. */
|
||||
|
||||
return -1;
|
||||
|
||||
else
|
||||
|
||||
{
|
||||
|
||||
/* Data read. */
|
||||
|
||||
fprintf (stderr, "Server: got message: `%s'\n", buffer);
|
||||
|
||||
return 0;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
int
|
||||
|
||||
main (void)
|
||||
|
||||
{
|
||||
|
||||
extern int make_Socket (uint16_t port);
|
||||
|
||||
int sock;
|
||||
|
||||
fd_set active_fd_set, read_fd_set;
|
||||
|
||||
int i;
|
||||
|
||||
struct sockaddr_in clientname;
|
||||
|
||||
size_t size;
|
||||
|
||||
/* Create the Socket and set it up to accept connections. */
|
||||
|
||||
sock = make_Socket (PORT);
|
||||
|
||||
if (listen (sock, 1) < 0)
|
||||
|
||||
{
|
||||
|
||||
perror ("listen");
|
||||
|
||||
exit (EXIT_FAILURE);
|
||||
|
||||
}
|
||||
|
||||
/* Initialize the set of active Sockets. */
|
||||
|
||||
FD_ZERO (&active_fd_set);
|
||||
|
||||
FD_SET (sock, &active_fd_set);
|
||||
|
||||
while (1)
|
||||
|
||||
{
|
||||
|
||||
/* Block until input arrives on one or more active Sockets. */
|
||||
|
||||
read_fd_set = active_fd_set;
|
||||
|
||||
if (select (FD_SETSIZE, &read_fd_set, NULL, NULL, NULL) < 0)
|
||||
|
||||
{
|
||||
|
||||
perror ("select");
|
||||
|
||||
exit (EXIT_FAILURE);
|
||||
|
||||
}
|
||||
|
||||
/* Service all the Sockets with input pending. */
|
||||
|
||||
for (i = 0; i < FD_SETSIZE; ++i)
|
||||
|
||||
if (FD_ISSET (i, &read_fd_set))
|
||||
|
||||
{
|
||||
|
||||
if (i == sock)
|
||||
|
||||
{
|
||||
|
||||
/* Connection request on original Socket. */
|
||||
|
||||
int new;
|
||||
|
||||
size = sizeof (clientname);
|
||||
|
||||
new = accept (sock,
|
||||
|
||||
(struct sockaddr *) &clientname,
|
||||
|
||||
&size);
|
||||
|
||||
if (new < 0)
|
||||
|
||||
{
|
||||
|
||||
perror ("accept");
|
||||
|
||||
exit (EXIT_FAILURE);
|
||||
|
||||
}
|
||||
|
||||
fprintf (stderr,
|
||||
|
||||
"Server: connect from host %s, port %hd.\n",
|
||||
|
||||
inet_ntoa (clientname.sin_addr),
|
||||
|
||||
ntohs (clientname.sin_port));
|
||||
|
||||
FD_SET (new, &active_fd_set);
|
||||
|
||||
}
|
||||
|
||||
else
|
||||
|
||||
{
|
||||
|
||||
/* Data arriving on an already-connected Socket. */
|
||||
|
||||
if (read_from_client (i) < 0)
|
||||
|
||||
{
|
||||
|
||||
close (i);
|
||||
|
||||
FD_CLR (i, &active_fd_set);
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
poll()
|
||||
|
||||
从写程序的角度来看,select 并不是一个很好的编程模型。一个好的编程模型应该直达本质,当网络请求发生状态变化的时候,核心是会发生事件。一个好的编程模型应该是直接抽象成消息:用户不需要用 select 来设置自己的集合,而是可以通过系统的 API 直接拿到对应的消息,从而处理对应的文件描述符。
|
||||
|
||||
比如下面这段伪代码就是一个更好的编程模型,具体的分析如下:
|
||||
|
||||
|
||||
poll 是一个阻塞调用,它将某段时间内操作系统内发生的且进程关注的消息告知用户程序;
|
||||
用户程序通过直接调用 poll 函数拿到消息;
|
||||
poll 函数的第一个参数告知内核 poll 关注哪些 Socket 及消息类型;
|
||||
poll 调用后,经过一段时间的等待(阻塞),就拿到了是一个消息的数组;
|
||||
通过遍历这个数组中的消息,能够知道关联的文件描述符和消息的类型;
|
||||
通过消息类型判断接下来该进行读取还是写入操作;
|
||||
通过文件描述符,可以进行实际地读、写、错误处理。
|
||||
|
||||
|
||||
while(true) {
|
||||
|
||||
events = poll(fds, ...)
|
||||
|
||||
for(evt in events) {
|
||||
|
||||
fd = evt.fd;
|
||||
|
||||
type = evt.revents;
|
||||
|
||||
if(type & POLLIN ) {
|
||||
|
||||
// 有数据需要读,读取fd中的数据
|
||||
|
||||
} else if(type & POLLOUT) {
|
||||
|
||||
// 可以写入数据
|
||||
|
||||
}
|
||||
|
||||
else ...
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
poll 虽然优化了编程模型,但是从性能角度分析,它和 select 差距不大。因为内核在产生一个消息之后,依然需要遍历 poll 关注的所有文件描述符来确定这条消息是否跟用户程序相关。
|
||||
|
||||
epoll
|
||||
|
||||
为了解决上述问题,epoll 通过更好的方案实现了从操作系统订阅消息。epoll 将进程关注的文件描述符存入一棵二叉搜索树,通常是红黑树的实现。在这棵红黑树当中,Key 是 Socket 的编号,值是这个 Socket 关注的消息。因此,当内核发生了一个事件:比如 Socket 编号 1000 可以读取。这个时候,可以马上从红黑树中找到进程是否关注这个事件。
|
||||
|
||||
另外当有关注的事件发生时,epoll 会先放到一个队列当中。当用户调用epoll_wait时候,就会从队列中返回一个消息。epoll 函数本身是一个构造函数,只用来创建红黑树和队列结构。epoll_wait调用后,如果队列中没有消息,也可以马上返回。因此epoll是一个非阻塞模型。
|
||||
|
||||
总结一下,select/poll 是阻塞模型,epoll 是非阻塞模型。当然,并不是说非阻塞模型性能就更好。在多数情况下,epoll 性能更好是因为内部有红黑树的实现。
|
||||
|
||||
最后我再贴一段用 epoll 实现的 Socket 服务给你做参考,这段程序的作者将这段代码放到了 Public Domain,你以后看到公有领域的代码可以放心地使用。
|
||||
|
||||
下面这段程序跟之前 select 的原理一致,对于每一个新的客户端连接,都使用 accept 拿到这个连接的文件描述符,并且创建一个客户端的 Socket。然后通过epoll_ctl将客户端的文件描述符和关注的消息类型放入 epoll 的红黑树。操作系统每次监测到一个新的消息产生,就会通过红黑树对比这个消息是不是进程关注的(当然这段代码你看不到,因为它在内核程序中)。
|
||||
|
||||
非阻塞模型的核心价值,并不是性能更好。当真的高并发来临的时候,所有的 CPU 资源,所有的网络资源可能都会被用完。这个时候无论是阻塞还是非阻塞,结果都不会相差太大。(前提是程序没有写错)。
|
||||
|
||||
epoll有 2 个最大的优势:
|
||||
|
||||
|
||||
内部使用红黑树减少了内核的比较操作;
|
||||
对于程序员而言,非阻塞的模型更容易处理各种各样的情况。程序员习惯了写出每一条语句就可以马上得到结果,这样不容易出 Bug。
|
||||
|
||||
|
||||
// Asynchronous Socket server - accepting multiple clients concurrently,
|
||||
|
||||
// multiplexing the connections with epoll.
|
||||
|
||||
//
|
||||
|
||||
// Eli Bendersky [http://eli.thegreenplace.net]
|
||||
|
||||
// This code is in the public domain.
|
||||
|
||||
#include <assert.h>
|
||||
|
||||
#include <errno.h>
|
||||
|
||||
#include <stdbool.h>
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
#include <stdio.h>
|
||||
|
||||
#include <stdlib.h>
|
||||
|
||||
#include <string.h>
|
||||
|
||||
#include <sys/epoll.h>
|
||||
|
||||
#include <sys/Socket.h>
|
||||
|
||||
#include <sys/types.h>
|
||||
|
||||
#include <unistd.h>
|
||||
|
||||
|
||||
|
||||
#include "utils.h"
|
||||
|
||||
|
||||
|
||||
#define MAXFDS 16 * 1024
|
||||
|
||||
|
||||
|
||||
typedef enum { INITIAL_ACK, WAIT_FOR_MSG, IN_MSG } ProcessingState;
|
||||
|
||||
|
||||
|
||||
#define SENDBUF_SIZE 1024
|
||||
|
||||
|
||||
|
||||
typedef struct {
|
||||
|
||||
ProcessingState state;
|
||||
|
||||
uint8_t sendbuf[SENDBUF_SIZE];
|
||||
|
||||
int sendbuf_end;
|
||||
|
||||
int sendptr;
|
||||
|
||||
} peer_state_t;
|
||||
|
||||
|
||||
|
||||
// Each peer is globally identified by the file descriptor (fd) it's connected
|
||||
|
||||
// on. As long as the peer is connected, the fd is unique to it. When a peer
|
||||
|
||||
// disconnects, a new peer may connect and get the same fd. on_peer_connected
|
||||
|
||||
// should initialize the state properly to remove any trace of the old peer on
|
||||
|
||||
// the same fd.
|
||||
|
||||
peer_state_t global_state[MAXFDS];
|
||||
|
||||
|
||||
|
||||
// Callbacks (on_XXX functions) return this status to the main loop; the status
|
||||
|
||||
// instructs the loop about the next steps for the fd for which the callback was
|
||||
|
||||
// invoked.
|
||||
|
||||
// want_read=true means we want to keep monitoring this fd for reading.
|
||||
|
||||
// want_write=true means we want to keep monitoring this fd for writing.
|
||||
|
||||
// When both are false it means the fd is no longer needed and can be closed.
|
||||
|
||||
typedef struct {
|
||||
|
||||
bool want_read;
|
||||
|
||||
bool want_write;
|
||||
|
||||
} fd_status_t;
|
||||
|
||||
|
||||
|
||||
// These constants make creating fd_status_t values less verbose.
|
||||
|
||||
const fd_status_t fd_status_R = {.want_read = true, .want_write = false};
|
||||
|
||||
const fd_status_t fd_status_W = {.want_read = false, .want_write = true};
|
||||
|
||||
const fd_status_t fd_status_RW = {.want_read = true, .want_write = true};
|
||||
|
||||
const fd_status_t fd_status_NORW = {.want_read = false, .want_write = false};
|
||||
|
||||
|
||||
|
||||
fd_status_t on_peer_connected(int sockfd, const struct sockaddr_in* peer_addr,
|
||||
|
||||
socklen_t peer_addr_len) {
|
||||
|
||||
assert(sockfd < MAXFDS);
|
||||
|
||||
report_peer_connected(peer_addr, peer_addr_len);
|
||||
|
||||
|
||||
|
||||
// Initialize state to send back a '*' to the peer immediately.
|
||||
|
||||
peer_state_t* peerstate = &global_state[sockfd];
|
||||
|
||||
peerstate->state = INITIAL_ACK;
|
||||
|
||||
peerstate->sendbuf[0] = '*';
|
||||
|
||||
peerstate->sendptr = 0;
|
||||
|
||||
peerstate->sendbuf_end = 1;
|
||||
|
||||
|
||||
|
||||
// Signal that this Socket is ready for writing now.
|
||||
|
||||
return fd_status_W;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
fd_status_t on_peer_ready_recv(int sockfd) {
|
||||
|
||||
assert(sockfd < MAXFDS);
|
||||
|
||||
peer_state_t* peerstate = &global_state[sockfd];
|
||||
|
||||
|
||||
|
||||
if (peerstate->state == INITIAL_ACK ||
|
||||
|
||||
peerstate->sendptr < peerstate->sendbuf_end) {
|
||||
|
||||
// Until the initial ACK has been sent to the peer, there's nothing we
|
||||
|
||||
// want to receive. Also, wait until all data staged for sending is sent to
|
||||
|
||||
// receive more data.
|
||||
|
||||
return fd_status_W;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
uint8_t buf[1024];
|
||||
|
||||
int nbytes = recv(sockfd, buf, sizeof buf, 0);
|
||||
|
||||
if (nbytes == 0) {
|
||||
|
||||
// The peer disconnected.
|
||||
|
||||
return fd_status_NORW;
|
||||
|
||||
} else if (nbytes < 0) {
|
||||
|
||||
if (errno == EAGAIN || errno == EWOULDBLOCK) {
|
||||
|
||||
// The Socket is not *really* ready for recv; wait until it is.
|
||||
|
||||
return fd_status_R;
|
||||
|
||||
} else {
|
||||
|
||||
perror_die("recv");
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
bool ready_to_send = false;
|
||||
|
||||
for (int i = 0; i < nbytes; ++i) {
|
||||
|
||||
switch (peerstate->state) {
|
||||
|
||||
case INITIAL_ACK:
|
||||
|
||||
assert(0 && "can't reach here");
|
||||
|
||||
break;
|
||||
|
||||
case WAIT_FOR_MSG:
|
||||
|
||||
if (buf[i] == '^') {
|
||||
|
||||
peerstate->state = IN_MSG;
|
||||
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case IN_MSG:
|
||||
|
||||
if (buf[i] == '$') {
|
||||
|
||||
peerstate->state = WAIT_FOR_MSG;
|
||||
|
||||
} else {
|
||||
|
||||
assert(peerstate->sendbuf_end < SENDBUF_SIZE);
|
||||
|
||||
peerstate->sendbuf[peerstate->sendbuf_end++] = buf[i] + 1;
|
||||
|
||||
ready_to_send = true;
|
||||
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Report reading readiness iff there's nothing to send to the peer as a
|
||||
|
||||
// result of the latest recv.
|
||||
|
||||
return (fd_status_t){.want_read = !ready_to_send,
|
||||
|
||||
.want_write = ready_to_send};
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
fd_status_t on_peer_ready_send(int sockfd) {
|
||||
|
||||
assert(sockfd < MAXFDS);
|
||||
|
||||
peer_state_t* peerstate = &global_state[sockfd];
|
||||
|
||||
|
||||
|
||||
if (peerstate->sendptr >= peerstate->sendbuf_end) {
|
||||
|
||||
// Nothing to send.
|
||||
|
||||
return fd_status_RW;
|
||||
|
||||
}
|
||||
|
||||
int sendlen = peerstate->sendbuf_end - peerstate->sendptr;
|
||||
|
||||
int nsent = send(sockfd, &peerstate->sendbuf[peerstate->sendptr], sendlen, 0);
|
||||
|
||||
if (nsent == -1) {
|
||||
|
||||
if (errno == EAGAIN || errno == EWOULDBLOCK) {
|
||||
|
||||
return fd_status_W;
|
||||
|
||||
} else {
|
||||
|
||||
perror_die("send");
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
if (nsent < sendlen) {
|
||||
|
||||
peerstate->sendptr += nsent;
|
||||
|
||||
return fd_status_W;
|
||||
|
||||
} else {
|
||||
|
||||
// Everything was sent successfully; reset the send queue.
|
||||
|
||||
peerstate->sendptr = 0;
|
||||
|
||||
peerstate->sendbuf_end = 0;
|
||||
|
||||
|
||||
|
||||
// Special-case state transition in if we were in INITIAL_ACK until now.
|
||||
|
||||
if (peerstate->state == INITIAL_ACK) {
|
||||
|
||||
peerstate->state = WAIT_FOR_MSG;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
return fd_status_R;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
int main(int argc, const char** argv) {
|
||||
|
||||
setvbuf(stdout, NULL, _IONBF, 0);
|
||||
|
||||
|
||||
|
||||
int portnum = 9090;
|
||||
|
||||
if (argc >= 2) {
|
||||
|
||||
portnum = atoi(argv[1]);
|
||||
|
||||
}
|
||||
|
||||
printf("Serving on port %d\n", portnum);
|
||||
|
||||
|
||||
|
||||
int listener_sockfd = listen_inet_Socket(portnum);
|
||||
|
||||
make_Socket_non_blocking(listener_sockfd);
|
||||
|
||||
|
||||
|
||||
int epollfd = epoll_create1(0);
|
||||
|
||||
if (epollfd < 0) {
|
||||
|
||||
perror_die("epoll_create1");
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
struct epoll_event accept_event;
|
||||
|
||||
accept_event.data.fd = listener_sockfd;
|
||||
|
||||
accept_event.events = EPOLLIN;
|
||||
|
||||
if (epoll_ctl(epollfd, EPOLL_CTL_ADD, listener_sockfd, &accept_event) < 0) {
|
||||
|
||||
perror_die("epoll_ctl EPOLL_CTL_ADD");
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
struct epoll_event* events = calloc(MAXFDS, sizeof(struct epoll_event));
|
||||
|
||||
if (events == NULL) {
|
||||
|
||||
die("Unable to allocate memory for epoll_events");
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
while (1) {
|
||||
|
||||
int nready = epoll_wait(epollfd, events, MAXFDS, -1);
|
||||
|
||||
for (int i = 0; i < nready; i++) {
|
||||
|
||||
if (events[i].events & EPOLLERR) {
|
||||
|
||||
perror_die("epoll_wait returned EPOLLERR");
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
if (events[i].data.fd == listener_sockfd) {
|
||||
|
||||
// The listening Socket is ready; this means a new peer is connecting.
|
||||
|
||||
|
||||
|
||||
struct sockaddr_in peer_addr;
|
||||
|
||||
socklen_t peer_addr_len = sizeof(peer_addr);
|
||||
|
||||
int newsockfd = accept(listener_sockfd, (struct sockaddr*)&peer_addr,
|
||||
|
||||
&peer_addr_len);
|
||||
|
||||
if (newsockfd < 0) {
|
||||
|
||||
if (errno == EAGAIN || errno == EWOULDBLOCK) {
|
||||
|
||||
// This can happen due to the nonblocking Socket mode; in this
|
||||
|
||||
// case don't do anything, but print a notice (since these events
|
||||
|
||||
// are extremely rare and interesting to observe...)
|
||||
|
||||
printf("accept returned EAGAIN or EWOULDBLOCK\n");
|
||||
|
||||
} else {
|
||||
|
||||
perror_die("accept");
|
||||
|
||||
}
|
||||
|
||||
} else {
|
||||
|
||||
make_Socket_non_blocking(newsockfd);
|
||||
|
||||
if (newsockfd >= MAXFDS) {
|
||||
|
||||
die("Socket fd (%d) >= MAXFDS (%d)", newsockfd, MAXFDS);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
fd_status_t status =
|
||||
|
||||
on_peer_connected(newsockfd, &peer_addr, peer_addr_len);
|
||||
|
||||
struct epoll_event event = {0};
|
||||
|
||||
event.data.fd = newsockfd;
|
||||
|
||||
if (status.want_read) {
|
||||
|
||||
event.events |= EPOLLIN;
|
||||
|
||||
}
|
||||
|
||||
if (status.want_write) {
|
||||
|
||||
event.events |= EPOLLOUT;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
if (epoll_ctl(epollfd, EPOLL_CTL_ADD, newsockfd, &event) < 0) {
|
||||
|
||||
perror_die("epoll_ctl EPOLL_CTL_ADD");
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
} else {
|
||||
|
||||
// A peer Socket is ready.
|
||||
|
||||
if (events[i].events & EPOLLIN) {
|
||||
|
||||
// Ready for reading.
|
||||
|
||||
int fd = events[i].data.fd;
|
||||
|
||||
fd_status_t status = on_peer_ready_recv(fd);
|
||||
|
||||
struct epoll_event event = {0};
|
||||
|
||||
event.data.fd = fd;
|
||||
|
||||
if (status.want_read) {
|
||||
|
||||
event.events |= EPOLLIN;
|
||||
|
||||
}
|
||||
|
||||
if (status.want_write) {
|
||||
|
||||
event.events |= EPOLLOUT;
|
||||
|
||||
}
|
||||
|
||||
if (event.events == 0) {
|
||||
|
||||
printf("Socket %d closing\n", fd);
|
||||
|
||||
if (epoll_ctl(epollfd, EPOLL_CTL_DEL, fd, NULL) < 0) {
|
||||
|
||||
perror_die("epoll_ctl EPOLL_CTL_DEL");
|
||||
|
||||
}
|
||||
|
||||
close(fd);
|
||||
|
||||
} else if (epoll_ctl(epollfd, EPOLL_CTL_MOD, fd, &event) < 0) {
|
||||
|
||||
perror_die("epoll_ctl EPOLL_CTL_MOD");
|
||||
|
||||
}
|
||||
|
||||
} else if (events[i].events & EPOLLOUT) {
|
||||
|
||||
// Ready for writing.
|
||||
|
||||
int fd = events[i].data.fd;
|
||||
|
||||
fd_status_t status = on_peer_ready_send(fd);
|
||||
|
||||
struct epoll_event event = {0};
|
||||
|
||||
event.data.fd = fd;
|
||||
|
||||
|
||||
|
||||
if (status.want_read) {
|
||||
|
||||
event.events |= EPOLLIN;
|
||||
|
||||
}
|
||||
|
||||
if (status.want_write) {
|
||||
|
||||
event.events |= EPOLLOUT;
|
||||
|
||||
}
|
||||
|
||||
if (event.events == 0) {
|
||||
|
||||
printf("Socket %d closing\n", fd);
|
||||
|
||||
if (epoll_ctl(epollfd, EPOLL_CTL_DEL, fd, NULL) < 0) {
|
||||
|
||||
perror_die("epoll_ctl EPOLL_CTL_DEL");
|
||||
|
||||
}
|
||||
|
||||
close(fd);
|
||||
|
||||
} else if (epoll_ctl(epollfd, EPOLL_CTL_MOD, fd, &event) < 0) {
|
||||
|
||||
perror_die("epoll_ctl EPOLL_CTL_MOD");
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
return 0;
|
||||
|
||||
}
|
||||
|
||||
|
||||
重新思考:I/O 模型
|
||||
|
||||
在上面的模型当中,select/poll 是阻塞(Blocking)模型,epoll 是非阻塞(Non-Blocking)模型。阻塞和非阻塞强调的是线程的状态,所以阻塞就是触发了线程的阻塞状态,线程阻塞了就停止执行,并且切换到其他线程去执行,直到触发中断再回来。
|
||||
|
||||
还有一组概念是同步(Synchrounous)和异步(Asynchrounous),select/poll/epoll 三者都是同步调用。
|
||||
|
||||
同步强调的是顺序,所谓同步调用,就是可以确定程序执行的顺序的调用。比如说执行一个调用,知道调用返回之前下一行代码不会执行。这种顺序是确定的情况,就是同步。
|
||||
|
||||
而异步调用则恰恰相反,异步调用不明确执行顺序。比如说一个回调函数,不知道何时会回来。异步调用会加大程序员的负担,因为我们习惯顺序地思考程序。因此,我们还会发明像协程的 yield 、迭代器等将异步程序转为同步程序。
|
||||
|
||||
由此可见,非阻塞不一定是异步,阻塞也未必就是同步。比如一个带有回调函数的方法,阻塞了线程 100 毫秒,又提供了回调函数,那这个方法是异步阻塞。例如下面的伪代码:
|
||||
|
||||
asleep(100ms, () -> {
|
||||
|
||||
// 100ms 或更多后到这里
|
||||
|
||||
// ...do some thing
|
||||
|
||||
})
|
||||
|
||||
// 100 ms 后到这里
|
||||
|
||||
|
||||
总结
|
||||
|
||||
总结下,操作系统给大家提供各种各样的 API,是希望满足各种各样程序架构的诉求。但总体诉求其实是一致的:希望程序员写的单机代码,能够在多线程甚至分布式的环境下执行。这样你就不需要再去学习复杂的并发控制算法。从这个角度去看,非阻塞加上同步的编程模型确实省去了我们编程过程当中的很多思考。
|
||||
|
||||
但可惜的是,至少在今天这个时代,多线程、并发编程依然是程序员们的必修课。因此你在思考 I/O 模型的时候,还是需要结合自己的业务特性及系统自身的架构特点,进行选择。I/O 模型并不是选择效率,而是选择编程的手段。试想一个所有资源都跑满了的服务器,并不会因为是异步或者非阻塞模型就获得更高的吞吐量。
|
||||
|
||||
那么通过以上的学习,你现在可以尝试来回答本讲关联的面试题目:select/poll/epoll 有什么区别?
|
||||
|
||||
【解析】这三者都是处理 I/O 多路复用的编程手段。select/poll 模型是一种阻塞模型,epoll 是非阻塞模型。select/poll 内部使用线性结构存储进程关注的 Socket 集合,因此每次内核要判断某个消息是否发送给 select/poll 需要遍历进程关注的 Socket 集合。
|
||||
|
||||
而 epoll 不同,epoll 内部使用二叉搜索树(红黑树),用 Socket 编号作为索引,用关注的事件类型作为值,这样内核可以在非常快的速度下就判断某个消息是否需要发送给使用 epoll 的线程。
|
||||
|
||||
|
||||
|
||||
|
101
专栏/重学操作系统-完/36(1)加餐练习题详解(七).md
Normal file
101
专栏/重学操作系统-完/36(1)加餐练习题详解(七).md
Normal file
@@ -0,0 +1,101 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
36 (1)加餐 练习题详解(七)
|
||||
今天我会带你把《模块七:网络和安全》中涉及的课后练习题,逐一讲解,并给出每个课时练习题的解题思路和答案。
|
||||
|
||||
练习题详解
|
||||
|
||||
33 | 互联网协议群(TCP/IP):多路复用是怎么回事?
|
||||
|
||||
【问题】IPv4 和 IPv6 有什么区别?
|
||||
|
||||
【解析】 IPv4 和 IPv6 最大的区别是地址空间大小不同。
|
||||
|
||||
|
||||
IPv4 是用 32 位描述 IP 地址,理论极限约在 40 亿 IP 地址;
|
||||
IPv6 是用 128 位描述 IP 地址,IPv6 可以大到给每个人都分配 40 亿个 IP 地址,甚至更多的 IP 地址。
|
||||
|
||||
|
||||
IPv4 地址不够用,因此需要划分子网。比如公司的几千台机器(计算机、手机),复用一个出口 IP 地址。子网内部,就用 192.168 开头的 IP 地址。
|
||||
|
||||
而 IPv6 地址够用,可以给全世界每台设备都分配一个地址,也可以给每一个组织(甚至家庭)都分配数以亿计的地址,目前不存在地址枯竭的问题。因此不需要像 IPv4 那样通过网络地址转换协议(NAT)去连接子网和外部网络。
|
||||
|
||||
因为地址数目的不同导致这两个协议在分配 IP 地址的时候行为也不一样。
|
||||
|
||||
IPv4 地址,空间小,如果没有一个中心的服务为所有设备分配地址,那么产生的冲突非常严重。所以IPv4 地址分配,是一种中心化的请求/返回的模式。客户端向服务端请求,分配地址。服务端,将计算好可以分配的地址返回给客户端。
|
||||
|
||||
而 IPv6 可以采用先计算,再申请的模式。由客户端自己随机抽取得出一个 IP 地址(能这样做是因为闲置的 IP 地址太多,随机抽取一个大概率没有设备使用),然后再向这个 IP 地址发送信息。如果没有得到返回,那么说明这个 IP 地址还没有设备使用。大体来说,这就是 IPv6 邻居发现协议,但上述内容只是其中该协议的一小部分。
|
||||
|
||||
以上是 IPv4 和 IPv6 最重要的几个区别。如果你对这块内容比较感兴趣,比如 IPv6 具体的地址格式?127.0.0.1 是什么 IP 地址?封包有什么区别?可以查阅更多的资料,比如 IPv6 的 RFC 文档。
|
||||
|
||||
34 | UDP 协议:UDP 和 TCP 相比快在哪里?
|
||||
|
||||
【问题】SSH(Secure Shell)工具可不可以用 UDP 实现?
|
||||
|
||||
【解析】SSH(Secure Shell)是一种网络加密协议,可以帮助我们在不安全的网络上构建安全的传输。和 HTTPS 类似,SSH 先用非对称加密。协商密钥和参数,在目标机器登录后。利用对称加密,建立加密通道(Channel)传输数据。
|
||||
|
||||
通常的 SSH 协议的底层要求是 TCP 协议。但是如果你愿意用 UDP 实现 SSH 需要的可靠性,就可以替代原有 TCP 协议的能力。只不过因为 SSH 协议对吞吐量要求并不高,而 TCP 的延迟也足够用,所以这样做的收益也不会非常的高。如果想构建安全的远程桌面,可以考虑在 UDP 上实现专门的安全传输协议来提高吞吐量、降低延迟。
|
||||
|
||||
事实上,安全传输协议也有建立在 UDP 之上的。比如说IBM 的FASP(Fast and Secure Protocol)协议,它不像 TCP 一样自动去判断封包丢失,也不会给每一个封包一个响应,它只重传接收方显示指定没有收到的封包。因而这个协议在传输文件的时候,有更快的速度。
|
||||
|
||||
35 | Linux 的 I/O 模型:select/poll/epoll 有什么区别?
|
||||
|
||||
【问题】如果用 epoll 架构一个Web 服务器应该是一个怎样的架构?
|
||||
|
||||
【解析】 每一个客户端连接进来之后都是一个 Socket 文件。接下来,对于 Web 服务器而言,要处理的是文件的 I/O,以及在 I/O 结束之后进行数据、业务逻辑的处理。
|
||||
|
||||
|
||||
I/O:这部分的主要开销在于从 Socket 文件中读出数据到用户空间。比如说读取出 HTTP 请求的数据并把它们存储到一个缓冲区当中。
|
||||
处理部分(Processing):这部分的开销有很多个部分。比如说,需要将 HTTP 请求从字节的表示转化为字符串的表示,然后再解析。还需要将 HTTP 请求的字符串,分成各个部分。头部(Header)是一个 Key-Value 的映射(Map)。Body 部分,可能是 QueryString,JSON,XML 等。完成这些处理之后,可能还会进行读写数据库、业务逻辑计算、远程调用等。
|
||||
|
||||
|
||||
我们先说处理部分(Processing) 的开销,目前主要有下面这样几种架构。
|
||||
|
||||
1. 为每一次处理创建一个线程。
|
||||
|
||||
这样做线程之间的相互影响最小。只要有足够多的资源,就可以并发完成足够多的工作。但是缺点在于线程的、创建和销毁成本。虽然单次成本不高,但是积累起来非常也是一个不小的数字——比如每秒要处理 1 万个请求的情况。更关键的问题在于,在并发高的场景下,这样的设计可能会导致创建的线程太多,导致线程切换太频繁,最终大量线程阻塞,系统资源耗尽,最终引发雪崩。
|
||||
|
||||
2. 通过线程池管理线程。
|
||||
|
||||
这样做最大的优势在于拥有反向压力。所谓反向压力(Back-Presure)就是当系统资源不足的时候可以阻塞生产者。对任务处理而言,生产者就是接收网络请求的 I/O 环节。当压力太大的时候,拒绝掉部分请求,从而缓解整个系统的压力。比如说我们可以控制线程池中最大的线程数量,一般会多于 CPU 的核数,小于造成系统雪崩的数量,具体数据需要通过压力测试得出。
|
||||
|
||||
3. 利用协程。
|
||||
|
||||
在一个主线程中实现更轻量级的线程,通常是实现协程或者类似的东西。将一个内核级线程的执行时间分片,分配给 n 个协程。协程之间会互相转让执行资源,比如一个协程等待 I/O,就会将计算资源转让给其他的协程。转换过程不需要线程切换,类似函数调用的机制。这样最大程度地利用了计算资源,因此性能更好。
|
||||
|
||||
最后强调一下,GO 语言实现的不是协程,是轻量级的线程,但是效果也非常好。Node.js 实现了类似协程的单位,称为任务,效果也很不错。Java 新标准也在考虑支持协程,目前也有一些讨论——考虑用 Java 的异常处理机制实现协程。你可以根据自己的研究或者工作方向去查阅更多相关的资料。
|
||||
|
||||
接下来我们说说 I/O 部分的架构。I/O 部分就是将数据从 Socket 文件中读取出来存储到用户空间的内存中去。我们将所有需要监听的 Socket 文件描述符,都放到 epoll 红黑树当中,就进入了一种高性能的处理状态。但是读取文件的操作,还有几种选择。
|
||||
|
||||
|
||||
单线程读取所有文件描述符的数据。 读取的过程利用异步 I/O,所以这个线程只需要发起 I/O 和响应中断。每次中断的时候,数据拷贝到用户空间,这个线程就将接收数据的缓冲区传递给处理模块。虽然这个线程要处理很多的 I/O,但因为只需要处理中断,所以压力并不大。
|
||||
多线程同步 I/O。 用很多个线程通过同步 I/O 的模式去处理文件描述符。这个方式在通常的情况下,可以完成工作。但是在高并发的场景下,会浪费很多的 CPU 资源。
|
||||
零拷贝技术, 通常和异步 I/O 结合使用。比如 mmap 处理过程——数据从磁盘文件读取到内核的过程不需要 CPU 的参与(DMA 技术),因此节省了大量开销。内核也不将数据再向用户空间拷贝,而是直接将缓冲区共享给用户空间,这样又节省了一次拷贝。但是需要注意,并不是所有的操作系统都支持这种模式。
|
||||
|
||||
|
||||
由此可见,优化 Web 服务器底层是在优化 I/O 的模型;中间层是在优化处理数据、远程调用等的模型。这两个过程要分开来看,都需要优化。
|
||||
|
||||
36 | 公私钥体系和网络安全:什么是中间人攻击?
|
||||
|
||||
【问题】如何预防中间人攻击?
|
||||
|
||||
【解析】中间人攻击最核心的就是要攻破信任链。比如说替换掉目标计算机中的验证程序,在目标计算机中安装证书,都可以作为中间人攻击的方式。因此在公司工作的时候,我们经常强调,要将电脑锁定再离开工位,防止有人物理破解。不要接收来历不明的邮件,防止一不小心被安装证书。也不要使用盗版的操作系统,以及盗版的软件。这些都是非法证书的来源。
|
||||
|
||||
另外一种情况就是服务器被攻破。比如内部员工机器中毒,密码泄露,导致黑客远程拿到服务器的私钥。再比如说,数据库被攻击、网站被挂码,导致系统被 Root。在这种情况下,黑客就可以作为中间人解密所有消息,为所欲为了。
|
||||
|
||||
安全无小事,在这里我再多说一句,平时大家不要将密码交给同事,也不要在安全的细节上掉以轻心。安全是所有公司的一条红线,需要大家一同去努力维护。
|
||||
|
||||
总结
|
||||
|
||||
这一讲我们学习了关于网络和安全的一些基本知识。我在网络方面挑选了两个传输层协议,TCP 和 UDP,主要的目标是给大家建立一种最基本的网络认知。然后我们基于网络一起探讨了 I/O 的模型和安全相关的知识。
|
||||
|
||||
学习 I/O 一方面是为了给公司省钱,另一方面是为了给用户提供更快的体验,还有一部分其实是为了安全生产。从操作系统层面来看,网络安全知识是它的延伸及周边知识。从工程师角度来看,这些知识都是重要的核心内容,也是面试的重点。如果想继续学习这部分的知识,你可以期待一下我即将在拉勾教育推出的《计算机网络》专栏。
|
||||
|
||||
好的,计算机网络相关的内容就告一段落。接下来,我们将开始操作系统的结束部分,我选取了虚拟化、Linux 设计哲学、商业操作系统 3 个主题和你分享,请和我一起来学习“模块八:虚拟化和其他”吧。
|
||||
|
||||
|
||||
|
||||
|
107
专栏/重学操作系统-完/36公私钥体系和网络安全:什么是中间人攻击?.md
Normal file
107
专栏/重学操作系统-完/36公私钥体系和网络安全:什么是中间人攻击?.md
Normal file
@@ -0,0 +1,107 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
36 公私钥体系和网络安全:什么是中间人攻击?
|
||||
设想你和一个朋友签订了合同,双方各执一份。如果朋友恶意篡改了合同内容,比如替换了合同中的条款,最后大家闹到法院、各执一词。这个时候就需要专业鉴定机构去帮你鉴定合同的真伪,朋友花越多心思去伪造合同,那么鉴定的成本就会越高。
|
||||
|
||||
在网络安全领域有个说法:没有办法杜绝网络犯罪,只能想办法提高网络犯罪的成本。我们的目标是提高作案的成本,并不是杜绝这种现象。今天我将带你初探网络安全的世界,学习网络安全中最重要的一个安全体系——公私钥体系。
|
||||
|
||||
合同的类比
|
||||
|
||||
我们尝试用签合同这种类比的方式来学习下面的内容。你可以先思考:如果选择“网签”,是不是能让伪造的成本更高呢?比如,是否能够降低存储的成本呢?
|
||||
|
||||
如果我们将两份合同都存到一个双方可以信任的第三方机构,只要这个机构不监守自盗,那么合同就是相对安全的。第三方机构保管后,合同的双方,都没有办法篡改这份合同的内容。而且双方随时可以去机构取出合同的原文,进行对比。
|
||||
|
||||
摘要算法
|
||||
|
||||
一家具有公信力的机构对内部需要严格管理。那么当合同存储下来之后,为了防止内部人员篡改合同,这家机构需要做什么呢?
|
||||
|
||||
很显然,这家机构需要证明合同没有被篡改。一种可行的做法,就是将合同原文和摘要一起存储。你可以把摘要算法理解成一个函数,原文经过一系列复杂的计算后,产生一个唯一的散列值。只要原文发生一丁点的变动,这个散列值就会发生变化。
|
||||
|
||||
目前比较常见的摘要算法有消息摘要算法(Message Digest Algorithm, MD5)和安全散列算法(Secure Hash Algorithm, SHA)。MD5 可以将任意长度的文章转化为一个 128 位的散列值。2004 年,MD5 被证实会发生碰撞,发生碰撞就是两篇原文产生了相同的摘要。这是非常危险的事情,这将允许黑客进行多种攻击手段,甚至可以伪造摘要。
|
||||
|
||||
因此在这之后,我们通常首选 SHA 算法。你不需要知道算法的准确运算过程,只需要知道 SHA 系的算法更加安全即可。在实现普通应用的时候可以使用 MD5,在计算对安全性要求极高的摘要时,就应该使用 SHA,比如订单、账号信息、证书等。
|
||||
|
||||
安全保存的困难
|
||||
|
||||
采用摘要算法,从理论上来说就杜绝了篡改合同的内容的做法。但在现实当中,公司也有可能出现内鬼。我们不能假定所有公司内部员工的行为就是安全的。因此可以考虑将合同和摘要分开存储,并且设置不同的权限。这样就确保在机构内部,没有任何一名员工同时拥有合同和摘要的权限。但是即便如此,依然留下了巨大的安全隐患。比如两名员工串通一气,或者员工利用安全漏洞,和外部的不法分子进行非法交易。
|
||||
|
||||
那么现在请你思考这个问题:如何确保公司内部的员工不会篡改合同呢?当然从理论上来说是做不到的。没有哪个系统能够杜绝内部人员接触敏感信息,除非敏感信息本身就不存在。因此,可以考虑将原文存到合同双方的手中,第三方机构中只存摘要。但是这又产生了一个新的问题,会不会有第三方机构的员工和某个用户串通一气修改合同呢?
|
||||
|
||||
至此,事情似乎陷入了僵局。由第三方平台保存合同,背后同样有很大的风险。而由用户自己保存合同,就是签约双方交换合同原文及摘要。但是这样的形式中,摘要本身是没有公信力的,无法证明合同和摘要确实是对方给的。
|
||||
|
||||
因此我们还要继续思考最终的解决方案:类比我们交换合同,在现实世界当中,还伴随着签名的交换。那么在计算机的世界中,签名是什么呢?
|
||||
|
||||
数字签名和证书
|
||||
|
||||
在计算机中,数字签名是一种很好的实现签名(模拟现实世界中签名)的方式。 所谓数字签名,就是对摘要进行加密形成的密文。
|
||||
|
||||
举个例子:现在 Alice 和 Bob 签合同。Alice 首先用 SHA 算法计算合同的摘要,然后用自己私钥将摘要加密,得到数字签名。Alice 将合同原文、签名,以及公钥三者都交给 Bob。如下图所示:
|
||||
|
||||
|
||||
|
||||
Bob 如果想证明合同是 Alice 的,就要用 Alice 的公钥,将签名解密得到摘要 X。然后,Bob 计算原文的 SHA 摘要 Y。Bob 对比 X 和 Y,如果 X = Y 则说明数据没有被篡改过。
|
||||
|
||||
在这样的一个过程当中,Bob 不能篡改 Alice 合同。因为篡改合同不但要改原文还要改摘要,而摘要被加密了,如果要重新计算摘要,就必须提供 Alice 的私钥。所谓私钥,就是 Alice 独有的密码。所谓公钥,就是 Alice 公布给他人使用的密码。
|
||||
|
||||
公钥加密的数据,只有私钥才可以解密。私钥加密的数据,只有公钥才可以解密。这样的加密方法我们称为非对称加密,基于非对称加密算法建立的安全体系,也被称作公私钥体系。用这样的方法,签约双方都不可以篡改合同。
|
||||
|
||||
证书
|
||||
|
||||
但是在上面描述的过程当中,仍然存在着一个非常明显的信任风险。这个风险在于,Alice 虽然不能篡改合同,但是可以否认给过 Bob 的公钥和合同。这样,尽管合同双方都不可以篡改合同本身,但是双方可以否认签约行为本身。
|
||||
|
||||
如果要解决这个问题,那么 Alice 提供的公钥,必须有足够的信誉。这就需要引入第三方机构和证书机制。
|
||||
|
||||
证书为公钥提供方提供公正机制。证书之所以拥有信用,是因为证书的签发方拥有信用。假设 Alice 想让 Bob 承认自己的公钥。Alice 不能把公钥直接给 Bob,而是要提供第三方公证机构签发的、含有自己公钥的证书。如果 Bob 也信任这个第三方公证机构,信任关系和签约就成立。当然,法律也得承认,不然没法打官司。
|
||||
|
||||
|
||||
|
||||
如上图所示,Alice 将自己的申请提交给机构,产生证书的原文。机构用自己的私钥签名 Alice 的申请原文(先根据原文内容计算摘要,再用私钥加密),得到带有签名信息的证书。Bob 拿到带签名信息的证书,通过第三方机构的公钥进行解密,获得 Alice 证书的摘要、证书的原文。有了 Alice 证书的摘要和原文,Bob 就可以进行验签。验签通过,Bob 就可以确认 Alice 的证书的确是第三方机构签发的。
|
||||
|
||||
用上面这样一个机制,合同的双方都无法否认合同。这个解决方案的核心在于需要第三方信用服务机构提供信用背书。这里产生了一个最基础的信任链,如果第三方机构的信任崩溃,比如被黑客攻破,那整条信任链条也就断裂了。
|
||||
|
||||
信任链
|
||||
|
||||
为了固化信任关系,减少风险。最合理的方式就是在互联网中打造一条更长的信任链,环环相扣,避免出现单点的信任风险。
|
||||
|
||||
|
||||
|
||||
上图中,由信誉最好的根证书机构提供根证书,然后根证书机构去签发二级机构的证书;二级机构去签发三级机构的证书;最后有由三级机构去签发 Alice 证书。
|
||||
|
||||
|
||||
如果要验证 Alice 证书的合法性,就需要用三级机构证书中的公钥去解密 Alice 证书的数字签名。
|
||||
如果要验证三级机构证书的合法性,就需要用二级机构的证书去解密三级机构证书的数字签名。
|
||||
如果要验证二级结构证书的合法性,就需要用根证书去解密。
|
||||
|
||||
|
||||
以上,就构成了一个相对长一些的信任链。如果其中一方想要作弊是非常困难的,除非链条中的所有机构同时联合起来,进行欺诈。
|
||||
|
||||
中间人攻击
|
||||
|
||||
最后我们再来说说中间人攻击。在 HTTPS 协议当中,客户端需要先从服务器去下载证书,然后再通过信任链验证服务器的证书。当证书被验证为有效且合法时,客户端和服务器之间会利用非对称加密协商通信的密码,双方拥有了一致的密码和加密算法之后,客户端和服务器之间会进行对称加密的传输。
|
||||
|
||||
在上述过程当中,要验证一个证书是否合法,就必须依据信任链,逐级的下载证书。但是根证书通常不是下载的,它往往是随着操作系统预安装在机器上的。如果黑客能够通过某种方式在你的计算机中预装证书,那么黑客也可以伪装成中间节点。如下图所示:
|
||||
|
||||
|
||||
|
||||
一方面,黑客向客户端提供伪造的证书,并且这个伪造的证书会在客户端中被验证为合法。因为黑客已经通过其他非法手段在客户端上安装了证书。举个例子,比如黑客利用 U 盘的自动加载程序,偷偷地将 U 盘插入客户端机器上一小段时间预装证书。
|
||||
|
||||
安装证书后,黑客一方面和客户端进行正常的通信,另一方面黑客和服务器之间也建立正常的连接。这样黑客在中间就可以拿到客户端到服务器的所有信息,并从中获利。
|
||||
|
||||
总结
|
||||
|
||||
总结一下,在信任的基础上才能产生合作。有了合作才能让整个互联网的世界有序运转,信任是整个互联网世界的基石。在互联网中解决信任问题不仅需要数学和算法,还需要一个信任链条。有人提供信用,比如证书机构;有人消费信用,比如网络服务的提供者。
|
||||
|
||||
这一讲我试图带你理解“如何构造一个拥有信誉的互联网世界”,但是还有很多的细节,比如说有哪些加密解密算法?HTTPS 协议具体的工作原理、架构等。这些更具体的内容,我会在拉勾教育即将推出的《计算机网络》专栏中和你继续深入讨论。
|
||||
|
||||
那么通过这一讲的学习,你现在可以尝试来回答本节关联的面试题目:什么是中间人攻击?
|
||||
|
||||
【解析】中间人攻击中,一方面,黑客利用不法手段,让客户端相信自己是服务提供方。另一方面,黑客伪装成客户端和服务器交互。这样黑客就介入了客户端和服务之间的连接,并从中获取信息,从而获利。在上述过程当中,黑客必须攻破信任链的体系,比如直接潜入对方机房现场暴力破解、诱骗对方员工在工作电脑中安装非法的证书等。
|
||||
|
||||
另外,有很多的网络调试工具的工作原理,和中间人攻击非常类似。为了调试网络的请求,必须先在客户端装上自己的证书。这样作为中间人节点的调试工具,才可以获取客户端和服务端之间的传输。
|
||||
|
||||
|
||||
|
||||
|
102
专栏/重学操作系统-完/37虚拟化技术介绍:VMware和Docker的区别?.md
Normal file
102
专栏/重学操作系统-完/37虚拟化技术介绍:VMware和Docker的区别?.md
Normal file
@@ -0,0 +1,102 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
37 虚拟化技术介绍:VMware 和 Docker 的区别?
|
||||
都说今天是一个云时代,其实云的本质就是由基础架构提供商提供基础架构,应用开发商不再关心基础架构。我们可以类比人类刚刚发明电的时候,工厂需要自己建电站,而现在只需要电线和插座就可以使用电。
|
||||
|
||||
云时代让我们可以在分钟、甚至秒级时间内获得计算、存储、操作系统等资源。设备不再论个卖,而是以一个虚拟化单位售卖,比如:
|
||||
|
||||
|
||||
用户可以买走一个 64 核 CPU 机器中的 0.25 个 CPU;
|
||||
也可以买走一个 128GB 内存机器中的 512M 内存;
|
||||
还可以买走 1⁄2 台机器三个小时了执行时间。
|
||||
|
||||
|
||||
实现以上这些,就需要虚拟化技术。这一讲我将以虚拟化技术中两种最具代表性的设计——VMware 和 Docker,为你解读解虚拟化技术。
|
||||
|
||||
什么是“虚拟化”
|
||||
|
||||
顾名思义,虚拟是相对于现实而言。虚拟化(Virutualization)通常是指构造真实的虚拟版本。不严谨地说,用软件模拟计算机,就是虚拟机;用数字模拟价值,就是货币;用存储空间模拟物理存储,就是虚拟磁盘。
|
||||
|
||||
VMware 和 Docker 是目前虚拟化技术中最具代表性的两种设计。VMware 为应用提供虚拟的计算机(虚拟机);Docker 为应用提供虚拟的空间,被称作容器(Container),关于空间的含义,我们会在下文中详细讨论。
|
||||
|
||||
VMware在 1998 年诞生,通过 Hypervisor 的设计彻底改变了虚拟化技术。2005 年,VMware 不断壮大,在全球雇用了 1000 名员工,成为世界上最大的云基础架构提供商。
|
||||
|
||||
Docker则是 2013 年发布的一个社区产品,后来逐渐在程序员群体中流行了起来。大量程序员开始习惯使用 Docker,所以各大公司才决定使用它。在“38 讲”中我们要介绍的 Kubernates(K8s)容器编排系统,一开始也是将 Docker 作为主要容器。虽然业内不时有传出二者即将分道扬镳的消息,但是目前(2021 年)K8s 下的容器主要还是 Docker。
|
||||
|
||||
虚拟机的设计
|
||||
|
||||
接下来我们说说虚拟机设计。要虚拟一台计算机,要满足三个条件:隔离、仿真、高效。
|
||||
|
||||
隔离(Isolation), 很好理解,指的是一台实体机上的所有的虚拟机实例不能互相影响。这也是早期设计虚拟机的一大动力,比如可以在一台实体机器上同时安装 Linux、Unix、Windows、MacOS 四种操作系统,那么一台实体机器就可以执行四种操作系统上的程序,这就节省了采购机器的开销。
|
||||
|
||||
仿真(Simulation)指的是用起来像一台真的机器那样,包括开机、关机,以及各种各样的硬件设备。在虚拟机上执行的操作系统认为自己就是在实体机上执行。仿真主要的贡献是让进程可以无缝的迁移,也就是让虚拟机中执行的进程,真实地感受到和在实体机上执行是一样的——这样程序从虚拟机到虚拟机、实体机到虚拟机的应用迁移,就不需要修改源代码。
|
||||
|
||||
高效(Efficient)的目标是减少虚拟机对 CPU、对硬件资源的占用。通常在虚拟机上执行指令需要额外负担10~15% 的执行成本,这个开销是相对较低的。因为应用通常很少将 CPU 真的用满,在容器中执行 CPU 指令开销会更低更接近在本地执行程序的速度。
|
||||
|
||||
为了实现上述的三种诉求,最直观的方案就是将虚拟机管理程序 Hypervisor 作为操作系统,在虚拟机管理程序(Hypervisor)之上再去构建更多的虚拟机。像这种管理虚拟机的架构,也称为 Type-1 虚拟机,如下图所示:
|
||||
|
||||
|
||||
|
||||
我们通常把虚拟机管理程序(Virtual Machine Monitor,VMM)称为 Hypervisor。在 Type-1 虚拟机中,Hypervisor一方面作为操作系统管理硬件,另一方面作为虚拟机的管理程序。在Hypervisor之上创建多个虚拟机,每个虚拟机可以拥有不同的操作系统(Guest OS)。
|
||||
|
||||
二进制翻译
|
||||
|
||||
通常硬件的设计假定是由单操作系统管理的。如果多个操作系统要共享这些设备,就需要通过 Hypervisor。当操作系统需要执行程序的时候,程序的指令就通过 Hypervisor 执行。早期的虚拟机设计当中,Hypervisor 不断翻译来自虚拟机的程序指令,将它们翻译成可以适配在目标硬件上执行的指令。这样的设计,我们称为二进制翻译。
|
||||
|
||||
二进制翻译的弱点在于性能,所有指令都需要翻译。相当于在执行所有指令的时候,都会产生额外的开销。当然可以用动态翻译技术进行弥补,比如说预读指令进行翻译,但是依然会产生较大的性能消耗。
|
||||
|
||||
世界切换和虚拟化支持
|
||||
|
||||
另一种方式就是当虚拟机上的应用需要执行程序的时候,进行一次世界切换(World Switch)。所谓世界切换就是交接系统的控制权,比如虚拟机上的操作系统,进入内核接管中断,成为实际的机器的控制者。在这样的条件下,虚拟机上程序的执行就变成了本地程序的执行。相对来说,这种切换行为相较于二进制翻译,成本是更低的。
|
||||
|
||||
为了实现世界切换,虚拟机上的操作系统需要使用硬件设备,比如内存管理单元(MMR)、TLB、DMA 等。这些设备都需要支持虚拟机上操作系统的使用,比如说 TLB 需要区分是虚拟机还是实体机程序。虽然可以用软件模拟出这些设备给虚拟机使用,但是如果能让虚拟机使用真实的设备,性能会更好。现在的 CPU 通常都支持虚拟化技术,比如 Intel 的 VT-X 和 AMD 的 AMD-V(也称作 Secure Virtual Machine)。如果你对硬件虚拟化技术非常感兴趣,可以阅读这篇文档。
|
||||
|
||||
Type-2 虚拟机
|
||||
|
||||
Type-1 虚拟机本身是一个操作系统,所以需要用户预装。为了方便用户的使用,VMware 还推出了 Type-2 虚拟机,如下图所示:
|
||||
|
||||
|
||||
|
||||
在第二种设计当中,虚拟机本身也作为一个进程。它和操作系统中执行的其他进程并没有太大的区别。但是为了提升性能,有一部分 Hypervisor 程序会作为内核中的驱动执行。当虚拟机操作系统(Guest OS)执行程序的时候,会通过 Hypervisor 实现世界切换。因此,虽然和 Type-1 虚拟机有一定的区别,但是从本质上来看差距不大,同样是需要二进制翻译技术和虚拟化技术。
|
||||
|
||||
Hyper-V
|
||||
|
||||
随着虚拟机的发展,现在也出现了很多混合型的虚拟机,比如微软的 Hyper-v 技术。从下图中你会看到,虚拟机的管理程序(Parent Partition)及 Windows 的核心程序,都会作为一个虚拟化的节点,拥有一个自己的 VMBus,并且通过 Hypervisor 实现虚拟化。
|
||||
|
||||
|
||||
|
||||
在 Hyper-V 的架构当中不存在一个主的操作系统。实际上,用户开机之后就在使用虚拟机,Windows 通过虚拟机执行。在这种架构下,其他的虚拟机,比如用 VMware 管理的虚拟机也可以复用这套架构。当然,你也可以直接把 Linux 安装在 Hyper-V 下,只不过安装过程没有 VMWare 傻瓜化,其实也是很不错的选择。
|
||||
|
||||
容器(Container)
|
||||
|
||||
虚拟机虚拟的是计算机,容器虚拟的是执行环境。每个容器都是一套独立的执行环境,如下图所示,容器直接被管理在操作系统之内,并不需要一个虚拟机监控程序。
|
||||
|
||||
|
||||
|
||||
和虚拟机有一个最大的区别就是:容器是直接跑在操作系统之上的,容器内部是应用,应用执行起来就是进程。这个进程和操作系统上的其他进程也没有本质区别,但这个架构设计没有了虚拟机监控系统。当然,容器有一个更轻量级的管理程序,用户可以从网络上下载镜像,启动起来就是容器。容器中预装了一些程序,比如说一个 Python 开发环境中,还会预装 Web 服务器和数据库。因为没有了虚拟机管理程序在中间的开销,因而性能会更高。而且因为不需要安装操作系统,因此容器安装速度更快,可以达到 ms 级别。
|
||||
|
||||
容器依赖操作系统的能力直接实现,比如:
|
||||
|
||||
|
||||
Linux 的 Cgroups(Linux Control Groups)能力,可以用来限制某组进程使用的 CPU 资源和内存资源,控制进程的资源能使用;
|
||||
另外Linux 的 Namespace 能力,可以设置每个容器能看到能够使用的目录和文件。
|
||||
|
||||
|
||||
有了这两个能力,就可以基本控制容器间的隔离,容器中的应用直接以进程的身份执行即可。进程间的目录空间、 CPU 资源已经被隔离了,所以不用担心互相影响。
|
||||
|
||||
总结
|
||||
|
||||
这一讲我们学习了 VMware 虚拟机和 Docker 容器的一些基本设计思路。虚拟机可以把一个完整的系统用若干个文件保存下来,因此迁移和复制都很容易。但是,与其启动一个操作系统,还不如直接打开应用,因此以 Docker 为代表的容器逐渐发展了起来。
|
||||
|
||||
容器虽然达到了虚拟机同样的隔离性,创建、销毁、维护成本都更低,但是从安全性考虑,还是要优先选用虚拟机执行操作系统。基础设施是一件大事,比如操作系统会发生故障、任何应用都有可能不安全,甚至容器管理程序本身也可能出现问题。因此,现在更多的情况是 Docker 被安装到了虚拟机上。
|
||||
|
||||
那么通过这一讲的学习,你现在可以尝试来回答本讲关联的面试题目:VMware 和 Docker 的区别?
|
||||
|
||||
【解析】 VMware 提供虚拟机,Docker 提供容器。 虚拟机是一台完整的计算机,因此需要安装操作系统。虚拟机中的程序执行在虚拟机的操作系统上,为了让多个操作系统可以高效率地同时执行,虚拟机非常依赖底层的硬件架构提供的虚拟化能力。容器则是利用操作系统的能力直接实现隔离,容器中的程序可以以进程的身份直接执行。
|
||||
|
||||
|
||||
|
||||
|
120
专栏/重学操作系统-完/38容器编排技术:如何利用K8s和DockerSwarm管理微服务?.md
Normal file
120
专栏/重学操作系统-完/38容器编排技术:如何利用K8s和DockerSwarm管理微服务?.md
Normal file
@@ -0,0 +1,120 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
38 容器编排技术:如何利用 K8s 和 Docker Swarm 管理微服务?
|
||||
作为操作系统的最后一个部分,我选择了三个主题:虚拟化、Linux 的架构哲学和商业操作系统的设计。我还是以探索式教学为主,帮助你建立和掌握虚拟化、程序架构、业务架构三个方向的基本概念。
|
||||
|
||||
操作系统的设计者和芯片的制造商们,早就感受到了虚拟化、容器化带来的变化,早早地支持了虚拟化,比如 Linux 的命名空间、Intel 的 VT-X 技术。这一讲作为虚拟化的一个延伸,我们一起讨论一下如何管理海量的容器,如何去构造一个高可用且具有扩展能力强的集群。
|
||||
|
||||
话不多说,让我们开始学习 Kubernetes 和 Docker Swarm 吧!
|
||||
|
||||
微服务
|
||||
|
||||
现在的面试官都喜欢问微服务相关的内容。微服务(Micro Service),指的是服务从逻辑上不可再分,是宏服务(Mono Service)的反义词。
|
||||
|
||||
比如初学者可能认为交易相关的服务都应该属于交易服务,但事实上,交易相关的服务可能会有交易相关的配置服务、交易数据的管理服务、交易履约的服务、订单算价的服务、流程编排服务、前端服务……
|
||||
|
||||
所以到底什么是不可再分呢?
|
||||
|
||||
其实没有不可再分,永远都可以继续拆分下去。只不过从逻辑上讲,系统的拆分,应该结合公司部门组织架构的调整,反映公司的战斗结构编排。但总的来说,互联网上的服务越来越复杂,几个简单的接口就可能形成一个服务,这些服务都要上线。如果用实体机来承载这些服务,开销太大。如果用虚拟机来承载这些服务倒是不错的选择,但是创建服务的速度太慢,不适合今天这个时代的研发者们。
|
||||
|
||||
试想你的系统因为服务太多,该如何管理?尤其是在大型的公司,员工通过自发组织架构评审就可以上线微服务——天长日久,微服务越来越多,可能会有几万个甚至几十万个。那么这么多的微服务,如何分布到数万台物理机上工作呢?
|
||||
|
||||
如下图所示,为了保证微服务之间是隔离的,且可以快速上线。每个微服务我们都使用一个单独的容器,而一组容器,又包含在一个虚拟机当中,具体的关系如下图所示:
|
||||
|
||||
|
||||
|
||||
上图中的微服务 C 因为只有一个实例存在单点风险,可能会引发单点故障。因此需要为微服务 C 增加副本,通常情况下,我们必须保证每个微服务至少有一个副本,这样才能保证可用性。
|
||||
|
||||
上述架构的核心就是要解决两个问题:
|
||||
|
||||
|
||||
减少 downtime(就是减少服务不可用的时间);
|
||||
支持扩容(随时都可以针对某个微服务增加容器)。
|
||||
|
||||
|
||||
因此,我们需要容器编排技术。容器编排技术指自动化地对容器进行部署、管理、扩容、迁移、保证安全,以及针对网络负载进行优化等一系列技术的综合体。Kubernetes 和 Docker Swarm 都是出色的容器编排方案。
|
||||
|
||||
Kubernetes
|
||||
|
||||
Kubernetes(K8s)是一个 Google 开源的容器编排方案。
|
||||
|
||||
节点(Master&Worker)
|
||||
|
||||
K8s 通过集群管理容器。用户可以通过命令行、配置文件管理这个集群——从而编排容器;用户可以增加节点进行扩容,每个节点是一台物理机或者虚拟机。如下图所示,Kubernetes 提供了两种分布式的节点。Master 节点是集群的管理者,Worker 是工作节点,容器就在 Worker 上工作,一个 Worker 的内部可以有很多个容器。
|
||||
|
||||
|
||||
|
||||
在我们为一个微服务扩容的时候,首选并不是去增加 Worker 节点。可以增加这个微服务的容器数量,也可以提升每个容器占用的 CPU、内存存储资源。只有当整个集群的资源不够用的时候,才会考虑增加机器、添加节点。
|
||||
|
||||
Master 节点至少需要 2 个,但并不是越多越好。Master 节点主要是管理集群的状态数据,不需要很大的内存和存储空间。Worker 节点根据集群的整体负载决定,一些大型网站还有弹性扩容的手段,也可以通过 K8s 实现。
|
||||
|
||||
单点架构
|
||||
|
||||
接下来我们讨论一下 Worker 节点的架构。所有的 Worker 节点上必须安装 kubelet,它是节点的管理程序,负责在节点上管理容器。
|
||||
|
||||
Pod 是 K8s 对容器的一个轻量级的封装,每个 Pod 有自己独立的、随机分配的 IP 地址。Pod 内部是容器,可以 1 个或多个容器。目前,Pod 内部的容器主要是 Docker,但是今后可能还会有其他的容器被大家使用,主要原因是 K8s 和 Docker 的生态也存在着竞争关系。总的来说,如下图所示,kubelet 管理 Pod,Pod 管理容器。当用户创建一个容器的时候,实际上在创建 Pod。
|
||||
|
||||
|
||||
|
||||
虽然 K8s 允许同样的应用程序(比如微服务),在一个节点上创建多个 Pod。但是为了保证可用性,通常我们会考虑将微服务分散到不同的节点中去。如下图所示,如果其中一个节点宕机了,微服务 A,微服务 B 还能正常工作。当然,有一些微服务。因为程序架构或者编程语言的原因,只能使用单进程。这个时候,我们也可能会在单一的节点上部署多个相同的服务,去利用更多的 CPU 资源。
|
||||
|
||||
|
||||
|
||||
负载均衡
|
||||
|
||||
Pod 的 IP 地址是动态的,如果要将 Pod 作为内部或者外部的服务,那么就需要一个能拥有静态 IP 地址的节点,这种节点我们称为服务(Service),服务不是一个虚拟机节点,而是一个虚拟的概念——或者理解成一段程序、一个组件。请求先到达服务,然后再到达 Pod,服务在这之间还提供负载均衡。当有新的 Pod 加入或者旧的 Pod 被删除,服务可以捕捉到这些状态,这样就大大降低了分布式应用架构的复杂度。
|
||||
|
||||
|
||||
|
||||
如上图所示,当我们要提供服务给外部使用时,对安全的考虑、对性能的考量是超过内部服务的。 K8s 解决方案:在服务的上方再提供薄薄的一层控制程序,为外部提供服务——这就是 Ingress。
|
||||
|
||||
以上,就是 K8s 的整体架构。 在使用的过程当中,相信你会感受到这个工具的魅力。比如说组件非常齐全,有数据加密、网络安全、单机调试、API 服务器等。如果你想了解更多的内容,可以查看这些资料。
|
||||
|
||||
Docker Swarm
|
||||
|
||||
Docker Swarm 是 Docker 团队基于 Docker 生态打造的容器编排引擎。下图是 Docker Swarm 整体架构图。
|
||||
|
||||
|
||||
|
||||
和 K8s 非常相似,节点被分成了 Manager 和 Worker。Manager 之间的状态数据通过 Raft 算法保证数据的一致性,Worker 内部是 Docker 容器。
|
||||
|
||||
和 K8s 的 Pod 类似,Docker Swarm 对容器进行了一层轻量级的封装——任务(Task),然后多个Task 通过服务进行负载均衡。
|
||||
|
||||
|
||||
|
||||
容器编排设计思考
|
||||
|
||||
这样的设计,用户只需要指定哪些容器开多少个副本,容器编排引擎自动就会在工作节点之中复制这些容器。而服务是容器的分组,多个容器共享一个服务。容器自动被创建,用户在维护的时候不需要维护到容器创建级别,只需要指定容器数目,并指定这类型的容器对应着哪个服务。至于之后,哪一个容器中的程序执行出错,编排引擎就会杀死这个出错的容器,并且重启一个新的容器。
|
||||
|
||||
在这样的设计当中,容器最好是无状态的,所以容器中最好不要用来运行 MySQL 这样的数据库。对于 MySQL 数据库,并不是多个实例都可以通过负载均衡来使用。有的实例只可以读,有的实例只可以写,中间还有 Binlog 同步。因此,虽然 K8s 提供了状态管理组件,但是使用起来可能不如虚拟机划算。
|
||||
|
||||
也是因为这种原因,我们现在倾向于进行无状态服务的开发。所有的状态都是存储在远程,应用本身并没有状态。当然,在开发测试环境,用容器来管理数据库是一个非常好的方案。这样可以帮助我们快速搭建、切换开发测试环境,并且可以做到一人一环境,互不影响,也可以做到开发环境、测试环境和线上环境统一。
|
||||
|
||||
总结
|
||||
|
||||
本讲我们讨论了两套容器编排引擎的 Kubernetes 和 Docker。如果继续深入学习,你会发现 K8s 功能更复杂,对细节的处理更灵活。而 Docker Swarm 虽然不强大,但是在部署一些小中型应用时,非常简单。因为 Docker 是大家都用熟练的东西,用类似使用 Docker 的方式部署,学习成本更低。
|
||||
|
||||
至于到底选择哪个?你可以根据自己的业务场景综合考虑。
|
||||
|
||||
另外,一些大厂通常还会有自己的一套容器编排引擎。这些架构未必用了开源领域的产品,也许会让程序员感受到非常痛苦。因为即便是一家强大的商业公司,在研发产品的时候还是很难做到像社区产品这样认真和专注。所以我希望,当你以后成为一名优秀的架构师,如果不想让公司的技术栈被社区淘汰,就要不断地进行技术升级。
|
||||
|
||||
那么通过这一讲的学习,你现在可以尝试来回答本讲关联的面试题目:如何利用 K8s 和 Docker Swarm 管理微服务?
|
||||
|
||||
【解析】这两个容器编排引擎都可以用来管理微服务。K8s 和 Docker Swarm 在使用微服务的时候有许多共性的步骤。
|
||||
|
||||
|
||||
制作容器镜像:我们就是要先制作容器,如果使用 Docker 作为容器,那就要写 DockerFile,然后生成容器镜像。
|
||||
上传镜像:制作好容器之后,我们往往会将容器上传到容器的托管平台。很多公司内部有自己的容器托管平台,这样下载容器的速度会非常快。
|
||||
搭建集群:再接下来,我们要搭建一个 K8s 或者 Docker Swarm 的集群,将节点添加进去。
|
||||
添加微服务 Pod/Task:然后我们要在集群中添加 Pod 的或者 Task,可以通过命令行工具,也可以通过书写配置文件。
|
||||
设置服务:为 Pod/Task 设置服务,之后都通过服务来访问容器内的应用。
|
||||
|
||||
|
||||
以上 5 个步骤是无论用哪个容器编排引擎都需要做的。具体使用过程当中,还有很多差异。比如,有的时候使用图形界面就可以完成上面的管理;不同的引擎配置文件,参数格式都会有差异。但是从整体架构到使用方式,它们都有着很大的相似性。因此你在学习容器编排引擎时,不应该着眼于学习某一个引擎,而是将它们看作一类知识,对比着学习。
|
||||
|
||||
|
||||
|
||||
|
105
专栏/重学操作系统-完/39Linux架构优秀在哪里.md
Normal file
105
专栏/重学操作系统-完/39Linux架构优秀在哪里.md
Normal file
@@ -0,0 +1,105 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
39 Linux 架构优秀在哪里
|
||||
我们在面试的时候经常会和面试官聊架构,多数同学可能会认为架构是一个玄学问题,讨论的是“玄而又玄”的知识——如同道德经般的开头“玄之又玄、众妙之门”。其实架构领域也有通用的语言,有自己独有的词汇。虽然架构师经常为了系统架构争得面红耳赤,但是即使发生争吵,大家也会遵守架构思想准则。
|
||||
|
||||
这些优秀的架构思想和准则,很大一部分来自早期的黑客们对程序语言编译器实现的探索、对操作系统实现方案的探索,以及对计算机网络应用发展的思考,并且一直沿用至今。比如现在的面向对象编程、函数式编程、子系统的拆分和组织,以及分层架构设计,依然沿用了早期的架构思路。
|
||||
|
||||
其中有一部分非常重要的思想,被著名的计算机科学家、Unix 代码贡献者 Douglas McIlroy 誉为 Unix 哲学,也是 Linux 遵循的设计思想。今天我就和你一起讨论下,这部分前人留下的思想精华,希望可以帮助到你日后的架构工作。
|
||||
|
||||
组合性设计(Composability)
|
||||
|
||||
Unix 系设计的哲学,都在和单体设计(Monolithic Design)和中心化唱反调。作为社区产品,开发者来自全世界不同的地方,这就构成了一个巨大的开发团队,自然会反对中心化。
|
||||
|
||||
而一个巨大的开发团队的管理,一定不能是 Mono 的。举个例子,如果代码仓库是Mono的,这意味着所有的代码都存放在一个仓库里。如果要上线项目中的一个功能,那所有项目中的代码都要一起上线,只要一个小地方出了问题,就会影响到全局。在我们设计这个系统的时候,应该允许不同的程序模块通过不同的代码仓库发布。
|
||||
|
||||
再比如说,整体的系统架构应该是可以组合的。比如文件系统的设计,每个目录可以有不同的文件系统,我们可以随时替换文件系统、接入新的文件系统。比如接入一个网络的磁盘,或者接入一个内存文件系统。
|
||||
|
||||
与其所有的程序工具模块都由自己维护,不如将这项权利分发给需要的人,让更多的人参与进来。让更多的小团队去贡献代码,这样才可以把更多的工具体验做到极致。
|
||||
|
||||
这个思想在面向对象以及函数式编程的设计中,同样存在。比如在面向对象中,我们会尽量使用组合去替代继承。因为继承是一种 Mono 的设计,一旦发生继承关系,就意味着父类和子类之间的强耦合。而组合是一种更轻量级的复用。对于函数式编程,我们有 Monad 设计(单子),本质上是让事物(对象)和处理事物(计算)的函数之间可以进行组合,这样就可以最小粒度的复用函数。
|
||||
|
||||
同理,Unix 系操作系统用管道组合进程,也是在最小粒度的复用程序。
|
||||
|
||||
管道设计(Pipeline)
|
||||
|
||||
提到最小粒度的复用程序,就必然要提到管道(Pipeline)。Douglas McIlroy 在 Unix 的哲学中提到:一个应用的输出,应该是另一个应用的输入。这句话,其实道出了计算的本质。
|
||||
|
||||
计算其实就是将一个计算过程的输出给另一个计算过程作为输入。在构造流计算、管道运算、Monad 类型、泛型容器体系时——很大程度上,我们希望计算过程间拥有一定的相似性,比如泛型类型的统一。这样才可以把一个过程的输出给到另一个过程的输入。
|
||||
|
||||
重构和丢弃
|
||||
|
||||
在 Unix 设计当中有一个非常有趣的哲学。就是希望每个应用都只做一件事情,并且把这件事情做到极致。如果当一个应用变得过于复杂的时候,就去重构这个应用,或者重新写一个应用。而不是在原有的应用上增加功能。
|
||||
|
||||
上述逻辑和商业策略是否有相悖的地方?
|
||||
|
||||
关于这个问题,我觉得需要你自己进行思考,我不能给你答案,但欢迎把你的想法和答案写在留言区,我们一起交流。
|
||||
|
||||
设想一下,我们把微信的聊天工具、朋友圈、短视频、游戏都做成不同的应用,是不是会更好一些?
|
||||
|
||||
这是一个见仁见智的问题。但是目前来看,如果把短视频做成一个单独的应用,比如抖音,它在全球已经拥有 10 几亿的用户了;如果把游戏做成一个单独的应用,比如王者荣耀和 LoL,它们深受程序员们和广大上班族的喜爱。
|
||||
|
||||
还有,以我多年从事大型系统开发的经验来看,我宁愿重新做一些微服务,也不愿意去重构巨大的、复杂的系统。换句话说,我更乐意将新功能做到新系统里面,而不是在一个巨大的系统上不断地迭代和改进。这样不仅节省开发成本,还可以把事情做得更好。从这个角度看,我们进入微服务时代,是一个不可逆的过程。
|
||||
|
||||
另外多说一句,如果一定要在原有系统上增加功能,也应该多重构。重构和重写原有的系统有很多的好处,希望你不要有畏难情绪。优秀的团队,总是处在一个代码不断迭代的过程。一方面是因为业务在高速发展,旧代码往往承接不了新需求;另一方面,是因为程序员本身也在不断地追求更好的架构思路。
|
||||
|
||||
而重构旧代码,还经常可以看到业务逻辑中出问题的地方,看到潜在的隐患和风险,同时让程序员更加熟悉系统和业务逻辑。而且程序的复杂度,并不是随着需求量线性增长的。当需求量超过一定的临界值,复杂度增长会变快,类似一条指数曲线。因此,控制复杂度也是软件工程的一个核心问题。
|
||||
|
||||
写复杂的程序就是写错了
|
||||
|
||||
我们经常听到优秀的架构师说,程序写复杂了,就是写错了。在 Unix 哲学中,也提出过这样的说法:写一个程序的时候,先用几周时间去构造一个简单的版本,如果发现复杂了,就重写它。
|
||||
|
||||
确实实际情景也是如此。我们在写程序的时候,如果一开始没有用对工具、没有分对层、没有选对算法和数据结构、没有用对设计模式,那么写程序的时候,就很容易陷入大量的调试,还会出现很多 Bug。优秀的程序往往是思考的过程很长,调试的时间很短,能够迅速地在短时间内完成测试和上线。
|
||||
|
||||
所以当你发现一段代码,或者一段业务逻辑很消耗时间的时候,可能是你的思维方式出错了。想一想是不是少了必要的工具的封装,或者遗漏了什么中间环节。当然,也有可能是你的架构设计有问题,这就需要重新做架构了。
|
||||
|
||||
优先使用工具而不是“熟练”
|
||||
|
||||
关于优先使用工具这个哲学,我深有体会。
|
||||
|
||||
很多程序员在工作当中都忽略了去积累工具。比如说:
|
||||
|
||||
|
||||
你经常要重新配置自己的开发环境,也不肯做一个 Docker 的镜像;
|
||||
你经常要重新部署自己的测试环境,而且有时候还会出现使用者太多而不够用的情况。即使这样的情况屡屡发生,也不肯做一下容器化的管理;
|
||||
Git 的代码提交之后,不会触发自动化测试,需要人工去点鼠标,甚至需要由资深的测试手动去测。
|
||||
|
||||
|
||||
很多程序员都认为自己对某项技术足够熟练了。因此,宁愿长年累月投入更多的时间,也不愿意主动跳脱出固化思维。宁愿不断使用某一项技术,而不愿意将重复劳动转化成工具。比如写一个小型的 ORM 框架、缓存引擎、业务容器……总之,养成良好的习惯,可以让开发效率越来越高。
|
||||
|
||||
在 Unix 哲学当中,有这样一条规则:有些人使用“熟练”而不是使用工具来减轻工作,即便是临时需要去构造一个工具,你也应该尽可能去尝试实现。
|
||||
|
||||
我们现在每天都用的 Git 版本控制工具,就是基于这样的哲学被构建出来的。当时刚好是 Linux 内核研发团队的商业代码管理工具到期了,Linux 的缔造者们基于这个经验教训,就自主研发了 Git 这款工具,不仅顺利地推进了后续的研发工作,还做成了一个巨大的程序员交友生态。
|
||||
|
||||
再给你讲一个我身边的故事:我刚刚工作的时候,我的老板自己写了一个小程序,去判断 HR 发过来简历是否符合他的用人条件。所以他每天可以看完几百份简历,并筛选出面试人选。而那些没有利用工具的技术 Leader,每天都在埋怨简历太多看不过来。
|
||||
|
||||
这些故事告诉我们,作为程序员,不仅仅需要完成工作,还要重视中间过程的工具缔造。
|
||||
|
||||
其他优秀的原则
|
||||
|
||||
我在学习 Unix 哲学的过程中,还看到很多有趣的规则,这里我摘选了一些和你分享。
|
||||
|
||||
比如:不要试图猜测程序可能的瓶颈在哪里,而是试图证明这个瓶颈,因为瓶颈会出现在出乎意料的地方。这句话告诉我们,要多写性能测试程序并且构造压力测试的场景。只有这样,才能让你的程序更健壮,承载更大的压力。
|
||||
|
||||
再比如:花哨的算法在业务规模小的时候通常运行得很慢,因此业务规模小的时候不要用花哨的算法。简单的算法,往往性能更高。如果你的业务规模很大,可以尝试去测试并证明需要用怎样的算法。
|
||||
|
||||
这也是我们在架构程序的时候经常会出错的地方。我们习惯性地选择用脑海中记忆的时间复杂度最低的算法,但是却忽略了时间复杂度只是一种增长关系,一个算法在某个场景中到底可不可行,是要以实际执行时收集数据为准的。
|
||||
|
||||
再比如:数据主导规则。当你的数据结构设计得足够好,那么你的计算方法就会深刻地反映出你系统的逻辑。这也叫作自证明代码。编程的核心是构造好的数据结构,而不是算法。
|
||||
|
||||
尽管我们在学习的时候,算法和数据结构是一起学的。但是在大牛们看来,数据结构的抽象可以深刻反映系统的本质。比如抽象出文件描述符反应文件、抽象出页表反应内存、抽象出 Socket 反应连接——这些数据结构才是设计系统最核心的东西。
|
||||
|
||||
总结
|
||||
|
||||
最后,再和你分享一句 Unix 的设计者Ken Thompson 的经典语录:搞不定就用蛮力。这是打破所有规则的规则。在我们开发的过程当中,首先要把事情搞定!只有把事情搞定,才有我们上面谈到的这一大堆哲学产生价值的可能性。事情没有搞定,一切都尘归尘土归土,毫无意义。
|
||||
|
||||
今天所讲的这些哲学,可以作为你平时和架构师们沟通的语言。架构有自己领域的语言,比如设计模式、编程范式、数据结构,等等。还有许多像 Unix 哲学这样——经过历史积淀,充满着人文气息的行业标准和规范。
|
||||
|
||||
如果你想仔细看看当时 Unix 的设计者都总结了哪些哲学,可以阅读这篇文档。
|
||||
|
||||
|
||||
|
||||
|
52
专栏/重学操作系统-完/40(1)加餐练习题详解(八).md
Normal file
52
专栏/重学操作系统-完/40(1)加餐练习题详解(八).md
Normal file
@@ -0,0 +1,52 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
40 (1)加餐 练习题详解(八)
|
||||
今天我会带你把《模块八:虚拟化和其他》中涉及的课后练习题,逐一讲解,并给出每一讲练习题的解题思路和答案。
|
||||
|
||||
|
||||
练习题详解
|
||||
|
||||
37 | 虚拟化技术介绍:VMware 和 Docker 的区别?
|
||||
|
||||
【问题】自己尝试用 Docker 执行一个自己方向的 Web 程序:比如 Spring/Django/Express 等?
|
||||
|
||||
【解析】关于如何安装 Docker,你可以参考这篇文档。然后这里还有一个不错的 SpringBoot+MySQL+Redis 例子,你可以参考这篇内容。
|
||||
|
||||
其他方向可以参考上面例子中的 Compose.yml 去定义自己的环境。 一般开发环境喜欢把所有工具链用 Compose 放到一起,上线的环境数据库一般不会用 Docker 容器。 Docker-Compose 是一个专门用来定义多容器任务的工具,你可以在这里得到。
|
||||
|
||||
国内镜像可以用 Aliyun 的,具体你可以参考这篇文档。
|
||||
|
||||
(注:需要一个账号并且登录)
|
||||
|
||||
38 | 容器编排技术:如何利用 K8s 和 Docker Swarm 管理微服务?
|
||||
|
||||
【问题】为什么会有多个容器共用一个 Pod 的需求?
|
||||
|
||||
【解析】Pod 内部的容器共用一个网络空间,可以通过 localhost 进行通信。另外多个容器,还可以共享一个存储空间。
|
||||
|
||||
比如一个 Web 服务容器,可以将日志、监控数据不断写入共享的磁盘空间,然后由日志服务、监控服务处理将日志上传。
|
||||
|
||||
再比如说一些跨语言的场景,比如一个 Java 服务接收到了视频文件传给一 个 OpenCV 容器进行处理。
|
||||
|
||||
以上这种设计模式,我们称为边车模式(Sidecar),边车模式将数个容器放入一个分组内(例如 K8s 的 Pod),让它们可以分配到相同的节点上。这样它们彼此间可以共用磁盘、网络等。
|
||||
|
||||
在边车模式中,有一类容器,被称为Ambassador Container,翻译过来是使节容器。对于一个主容器(Main Container)上的服务,可以通过 Ambassador Container 来连接外部服务。如下图所示:
|
||||
|
||||
|
||||
|
||||
我们在开发的时候经常会配置不同的环境。如果每个 Web 应用都要实现一套环境探测程序,比如判断是开发、测试还是线上环境,从而连接不同的 MySQL、Redis 等服务,那么每个项目都需要引入一个公用的库,或者实现一套逻辑。这样我们可以使用一个边车容器,专门提供数据库连接的服务。让连接服务可以自动探测环境,并且从远程读取全局配置,这样每个项目的开发者不需要再关心数据库有多少套环境、如何配置了。
|
||||
|
||||
总结
|
||||
|
||||
“[39 | Linux 架构优秀在哪里?]”和 “40 | 商业操作系统:电商操作系统是不是一个噱头?”因为这两讲内容人文色彩较重,我没有给你设置课后习题。但是如果你对这两讲的内容感兴趣,可以在留言区和我交流。
|
||||
|
||||
到这里,《重学操作系统》专栏的全部知识都已经讲解结束了。在这 40 讲中,我试图用通俗易懂的语言帮助你建立整个《操作系统》的知识体系,并且最大程度地帮助你将这些基础知识发散到实战场景中去。
|
||||
|
||||
在我看来,基础知识是相通的,学习是为了思考和解决问题。《操作系统》和《计算机组成原理》可以作为入门编程领域的前两门课,后续我会继续努力写出更多帮助你提升基础技能、开阔视野、加深认知的专栏课程。
|
||||
|
||||
|
||||
|
||||
|
83
专栏/重学操作系统-完/40商业操作系统:电商操作系统是不是一个噱头?.md
Normal file
83
专栏/重学操作系统-完/40商业操作系统:电商操作系统是不是一个噱头?.md
Normal file
@@ -0,0 +1,83 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
40 商业操作系统:电商操作系统是不是一个噱头?
|
||||
关于电商操作系统是不是一个噱头?我觉得对于想要哄抬股价、营造风口的资本来说,这无疑是一场盛宴。但是对于从事多年业务架构,为了这件事情努力的架构师们而言,这似乎不是一个遥远的梦想,而是可以通过手中的键盘、白板上的图纸去付诸实践的目标。
|
||||
|
||||
我们暂且不为这个问题是不是噱头定性,不如先来聊一聊什么是商业操作系统,聊一聊它的设计思路和基本理念。
|
||||
|
||||
进程的抽象
|
||||
|
||||
你可以把一个大型的电商公司想象成一个商业操作系统,它的目标是为其中的每个参与者分配资源。这些资源不仅仅是计算资源,还会有市场资源、渠道资源、公关资源、用户资源,等等。
|
||||
|
||||
这样操作系统上的进程也被分成了几种类别,比如说内核程序,其实就是电商公司。应用程序就包括商家、供应商、品牌方、第三方支付、大数据分析公司等一系列组织的策略。
|
||||
|
||||
接下来,我们以商家为例讨论进程。在操作系统中进程是应用程序的执行副本。它不仅仅是在内核的进程表中留下一条记录,它更像拥有独立思考能力的人,它需要什么资源就会自己去操作系统申请。它会遵循操作系统的规则,为自己的用户服务,完成自己的商业目的。
|
||||
|
||||
所以如果上升到操作系统的高度来设计电商系统。我们不仅要考虑如何在数据库表中记录这个商家、如何实现跟这个商家相关的业务逻辑,还要让商家的行为是定制化的,可以自发地组织营业。同时,也要服从平台制定的规则,共同维护商业秩序,比如定价策略、物流标准、服务水平,等等。
|
||||
|
||||
你可能会说,要达到这点其实很容易。实现一个开放平台,将所有的平台能力做成 API。让商家可以自己开发程序,去调用这些 API 来完成自己的服务。商家可以利用这些接口自定义自己的办公自动化软件。
|
||||
|
||||
事实上很多电商公司也确实是这样去做的,但我认为这样做没有抓住问题的核心。一方面是系统的开发、对接成本会难住很多中小型商家。但最重要的并不是研发成本,而是开放的 API 平台通常只能提供基础能力——比如说订单查询、商品创建、活动创建,等等。这些能力是电商平台已有能力的一种投影,超不过商家本身能在后台中配置和使用的范畴,基于这样的 API 架构出来的应用程序,可以节省商家的时间,但是不能称为进程。因为独立性不够,且不够智能。
|
||||
|
||||
所以真正的发展方向和目标是商业的智能化。这里有一个在游戏领域常见的设计模式,可以实现智能化,叫作代理人(Agent)模式。就是为每一个商家提供一个或者多个代理(Agent)程序。这些代理人像机器人一样,会帮助商家运营自己的网店、客服、物流体系,等等。
|
||||
|
||||
代理人知道什么时候应该做什么,比如说:
|
||||
|
||||
|
||||
帮商家预约物流、为新老用户提供不同的服务;
|
||||
通过分析数据决定是否需要花钱做活动;
|
||||
当品牌方有活动的时候,帮助商家联系;
|
||||
当线上商店经营出现问题的时候,主动帮商家分析;
|
||||
……
|
||||
|
||||
|
||||
你可以把代理人理解成一个游戏的 AI,它们会根据一些配置选项自发地完成任务。而代理人的提供者,也就是程序员,只需要证明在某些方面,代理人比人更优秀即可。而在这些优秀的方面,就可以交给代理人处理。
|
||||
|
||||
这样,商家放弃了一部分的管理权限,也减轻了很大的负担,成了代理人决策中的某个节点——比如有时候需要邮件确认一些内容、有时候需要支付运营费用、有时候会遵循代理人的建议对商店进行装修等。
|
||||
|
||||
资源和权限
|
||||
|
||||
对于一个计算机上的操作系统而言,我们对进程使用了什么样的资源并不是非常的敏感。而对于一个商业操作系统来说,我们就需要设计严格的权限控制。因为权限从某种意义上就代表着收入,代表着金钱。
|
||||
|
||||
资源是一个宽泛的概念。广告位是资源,可以带来直接的流量。基于用户的历史行为,每个用户看到的广告位是不同的,这个也叫作“千人千面”,所以一个广告位可以卖给很多个代理人。站内信、用户召回的权限也可以看作资源。 有权利建立自己的会员体系,可以看作资源。数据分析的权限可以看作资源。如果将商业系统看作一个操作系统,资源就是所有在这个系统中流通的有价值的东西。
|
||||
|
||||
有同学可能会认为,一切资源都可以用数据描述,那么权限控制也应该会比较简单。比如说某一个推广位到底给哪个商家、到底推广多长时间……
|
||||
|
||||
其实并不是这样,虽然有很多权限可以用数据描述但是并不好控制。比如一个商品,“商家最低可以设置多少价格”就是一件非常不好判断的事情。商品有标品也有非标品,标品的价格好控制,非标品的价格缺少参照。如果平台方不希望花费太多精力在价格治理上,就要想办法让这些不守规则的商家无法盈利。比如说一旦发现恶性价格竞争,或者虚报价格骗钱的情况,需要及时给予商家打击和处罚。
|
||||
|
||||
和权限对应的就是资源。如果让商家以代理人的身份在操作系统中运行,那么这个代理人可以使用多少资源,就需要有一个访问权限控制列表(Access Control List,,ACL)。这里有一个核心的问题,在传统的 ACL 设计中,是基于权限的管控,而不是权限、内容的发现。而对于设计得优秀的代理人 (Agent),应该是订阅所有的可能性,知道如何获取、申请所有的权限,然后不断思考怎样做更好。对代理人而言,这不是一个权限申请的问题,而是一个最优化策略——思考如何盈利。
|
||||
|
||||
策略
|
||||
|
||||
商家、组织在操作系统上化身成为代理人,也就是进程。商业操作系统的调度不仅仅体现在给这些代理人足够的计算、存储资源,更重要的是为这些代理人的决策提供上下文以及资源。
|
||||
|
||||
就好像真实的人一样:听到、看到、触摸到,然后做决策。做决策需要策略,一个好的策略可能是赚钱的,而一个坏的策略可能是灾难性的。从人做决策到机器做决策,有一个中间的过程。一开始的目标可以设立在让机器做少量的决策,比如说,机器通过观察近期来到商店用户的行为,决定哪些商品出现在店铺的首页上。但是在做这个决策之前,机器需要先咨询人的意愿。这样就把人当成了决策节点,机器变成了工具人。这样做一方面为人节省了时间,一方面也避免了错误。
|
||||
|
||||
再比如说机器可以通过数据预估一个广告位的收益,通过用户集群的画像得知在某个广告位投放店铺广告是否划算。如果机器得到一个正向的结果,可能会通知商家来完成付费和签约。那么问题来了,商家是否可以放心将付费和签约都交给机器呢?
|
||||
|
||||
当然不可以。如果家里急着用钱,可能就无法完成这笔看上去是划算的交易。另外,如果有其他的商家也看上了这个广告位。可能就需要竞价排名,所以需要人和机器的混合决策。
|
||||
|
||||
上述的模式会长期存在,例如设置价格是一个复杂的模型——疫情来了,口罩的销量会上升。机器可以理解这个口罩销量上升的过程,但是机器很难在疫情刚刚开始、口罩销量还没有上升的时候就预判到这个趋势。如果逻辑是确定的,那机器可以帮人做到极致,但如果逻辑不确定呢?如果很多判断是预判,是基于复杂的现实世界产生的思考,那么这就不是机器擅长的领域了。
|
||||
|
||||
所以智能的目标并不是替代人,而是让人更像人、机器更像机器。
|
||||
|
||||
另外再和你聊一下我自己的观点,以自动驾驶为例。如果一个完全自动驾驶的汽车发生车祸,那么应该由汽车制造商、算法的提供方、自动驾驶设备的提供方、保险公司来共同来承担责任。类比下,如果策略可以售卖,那么提供策略的人就要承担相应的责任。比如说策略出现故障,导致营销券被大量套现,那提供策略方就需要承担相应的赔偿。
|
||||
|
||||
在可预见到的未来,策略也会成为一种可交易的资源。维护一个网上商店,从原材料到生产加工、渠道、物流体系、获客、销售环节,再到售后——以目前的技术水平,可以实现到一种半人工参与的状态。但这样也产生了很多非常现实的问题,比如说,既然开店变得如此容易,那资本为什么不自己开店。这样去培养合格、服务态度更好的店员不是更加容易吗?
|
||||
|
||||
这也是互联网让人深深担忧的原因之一。所有的东西被自动化之后,代表着一种时代的变迁,剩下不能够自动化的,都变成了“节点”。很多过程不需要人参与之后,人就变成了在某些机器无法完成工作的节点上不断重复劳动的工具——这也是近年来小朋友们经常说自己是“工具人”的原因了。
|
||||
|
||||
而且,我们程序员是在推动这样的潮流。因此你可以想象,未来对程序员的需求是很大的。一个普通的商店可能会雇佣一名程序员,花上半年匠心打造某个策略,收费标准可能会像现在的住房装修一样贵。这个策略成功之后还会进行微调,这就是后期的服务费用。完全做到配置化的策略,会因为不够差异化,无法永久盈利。最终在商业市场上竞争的,会是大量将人作为决策节点的 AI。
|
||||
|
||||
总结
|
||||
|
||||
商业是人类繁荣后的产物,电商是信息时代商业早期形式,未来的发展方向一定是像一个操作系统那样,让每个实体,都可以有自己的策略。用户可以写策略订餐,比如说我每天中午让 AI 帮助我挑选、并订一份午餐。商家写策略运营,比如运营网店。
|
||||
|
||||
至于商业操作系统到底是不是一个噱头?我觉得这是商业的发展方向。操作系统上的进程应该是策略,或者说是机器人。这样的未来也让我深深的焦虑过:它可能让人失去工作,让连接变得扁平,焦虑散播在加速——这些问题都需要解决,而解决需要时间、需要探索。
|
||||
|
||||
|
||||
|
||||
|
39
专栏/重学操作系统-完/41结束语论程序员的发展——信仰、选择和博弈.md
Normal file
39
专栏/重学操作系统-完/41结束语论程序员的发展——信仰、选择和博弈.md
Normal file
@@ -0,0 +1,39 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
41 结束语 论程序员的发展——信仰、选择和博弈
|
||||
历时 5 个多月、40 讲的操作系统知识我们已经学完了。更准确地说应该是 50 讲,这最后一讲我想和你聊聊,作为一名程序员,我的职业观,以及我是如何进行选择的。
|
||||
|
||||
信仰
|
||||
|
||||
我觉得一切选择的根源是自己相信的东西,简单理解你可以说这就是信仰。信仰是如同地下车库中看不见的龙那样的事物,从哥德尔不完备性定理上去看信仰,它既不可以被证明也不可以被证伪,但是这是支撑你一切行为的基础。
|
||||
|
||||
相信知识的家庭砸锅卖铁让孩子上大学;不相信知识了家庭,冲进厕所,撕了孩子手上的《三国演义》。我相信知识改变命运,它毫无道理,毫无依据,没有办法证明,亦无法证伪,它完全可以自圆其说,但是却又找不到源头,可是就是这种虚无缥缈的东西左右着我的选择。
|
||||
|
||||
选择
|
||||
|
||||
有了信仰,自然而然,人就会选择。比如我林䭽的信仰是“知识改变命运”,而获取知识需要渠道和时间。
|
||||
|
||||
拓展渠道就要虚心地请教拥有知识的人,不能吝啬请客吃饭的钱,过节要给技术大牛筹备礼物,花钱买书不能心疼。为了节省时间,就需要租下公司边上很贵的房子,去节省上下班的时间。哪怕我工资将将过万的时候,我也愿意花 5000 块钱去租公司旁边的房子。
|
||||
|
||||
那么做这些事情,是对还是错呢?——我永远都无法去证明这些答案的对错,甚至我们得不到答案。不过有了相信的东西是美好的,因为你选择的时候不需要焦虑和犹豫。有了相信的东西,不去做,一定会后悔。这不是我给你的建议,我不太喜欢给人以人生大道理和建议,我觉得每个人思考的方式是不同的。我只能告诉你,我在这样思考问题。其实你也可以在留言区和我交流你的想法,和大家一起交流。
|
||||
|
||||
博弈
|
||||
|
||||
做出了选择,就会承担后果,这就博弈。每一个选择都有两面性,可能成功,也可能失败,所以是在博弈。
|
||||
|
||||
拿时间换来知识,知识不一定能用上。熬夜去背面试题,明天面试官也未见得会考到你背好的题目。花 1 年刷算法题,将来能写几个算法?
|
||||
|
||||
所以这个时候,我们需要的是相信。支撑人走到最后的东西。一定是你相信的东西。如果你相信善,那你就将它贯彻到底。也许会得到回报,多数我遇到的情况是这种回报未必就是我一开始设想的。因为所有东西都会出现变数,我知道多数人想做优秀的程序员。但是你有没有相信过,未来的 50 年程序将继续改变这个世界?你信不信,黑客的精神,依然会在未来的 100 年内延续。这些东西没有办法证明,只有相信。我相信!也许你不信,这不重要。
|
||||
|
||||
用我自己感受来说,在我人生的某个时候,我也曾经觉得《原神》比程序好玩。但是我玩腻了游戏,就要回去写程序了。写程序的时候,给了我人生一种满足感,是和游戏的满足感不一样的。
|
||||
|
||||
我现在所说的并不是一次心灵的鸡汤,不是告诉你“爱拼就会赢”这种无法证明的道理。我是把一个工作了 11 年的资深程序员的感受告诉给你:当为了自己所相信的东西去努力的时候,人的快乐和幸福指数会高一些。
|
||||
|
||||
以上就是我,对程序员职业发展的一点见解。最后还是感谢你来学习我的专栏,我会继续努力。将更多、更难的知识,以简单、有趣的形式带入你的视野,帮助你成长。如果你感兴趣,今后可以和我一起学习。
|
||||
|
||||
|
||||
|
||||
|
Reference in New Issue
Block a user