操作系统与浏览器原理
进程与线程?
为什么需要进程?在分时系统中,对于程序的管理是必要的。就比如两个程序A和B都要去写同一块磁盘的同一段地址空间,这就会产生冲突,需要第三方介入去管理谁先谁后,这个第三方就是操作系统。所以,为了便于描述对程序的控制,学术上就有了进程这个名词,实际上它就代指一个程序。操作系统通过一些手段比如 PCB 表、中断、信号、调度算法来实现多进程的管理,从而最大化对 CPU 时间的利用率。这就是进程的相关概念。
为什么需要线程?在一个多进程的应用程序中,进程之间的切换开销是很大的。这是因为进程是操作系统管理的,切换需要进内核。于是各种语言就去实现一个用户级的线程,相当于一个程序拿到时间片后去做操作系统做的事,就是对时间片再分配给各个程序段,从而实现子线程。由于这种线程是在用户态的,切换的时候不需要进内核,所以效率很高。多个线程之间,也实现了和多个进程之间一样的锁,用于实现资源互斥共享。
调度算法(了解即可)
传统作业:先来先服务、最短作业优先、高响应比优先(等待时间/要求服务时间)
分时系统:时间片轮转调度、最高优先级调度(静态/动态优先级、抢占式/非抢占式)、多级反馈队列(就是一个队列套队列,多个优先级)
锁和死锁
由于存在资源占有的问题,所以就需要给某个变量上锁。比如两个进程都需要对某个地址空间进行写操作,显然这个写操作是存在冲突的。用 C 语言可以在要持有变量的程序前面写一行上锁代码 pthread_mutex_lock, 在修改完变量之后使用 unlock 来释放掉锁。
死锁,就是说两个进程或者线程,都在等待对方释放锁,形成了循环等待。
死锁产生的四个必要条件:
- 资源互斥,即一个资源无法同时被多个进程持有;
- 非抢占式,一旦一个资源被某个进程持有,别的进程不可以剥夺;
- 请求和保持:进程已经持有了该资源,但还是需要请求其他的已经加锁的资源;
- 循环等待,在上述条件下,构成了一条请求-等待的循环链,第一个进程等第二个,第二个等第三个...一直到最后一个等第一个。
如何避免死锁:回答出上述四个条件,指出破坏其中一条即可。由于前三条总是不可避免的需要满足,所以一般是破坏最后一个条件,也就是去显式的设计一条 lock ordering ,也就是显式的说明加锁顺序。将整个加锁顺序进行拓扑排序,我们就可以避免出现有环图。
互斥设计的发展
- 先人设计:皮特森算法,高级语言层面利用代码逻辑来实现互斥的资源访问
- 原子操作:在汇编语言或者高级语言中实现一组原子操作,用于被编译器编译成不可被拆分的一系列操作,从而强制代码执行顺序。
- 自旋锁:通过原子操作实现,但存在一个线程拿到锁后,其他线程拿到时间片后 retry 锁状态,cpu 无效计算的问题。
- 互斥锁:将锁的控制权交给第三方,当一个进程抢锁占用失败后,就进入挂起状态,由线程库维护一个挂起队列,等到锁被释放的时候,主线程才会将锁直接交给阻塞线程。
- 条件变量:互斥锁只实现了资源的互斥控制,同时保证了 cpu 时间片不会因为锁的抢占而自旋。但是锁的使用一般都需要搭配条件来使用,即“在满足某个条件下,我才会去做 xxx,而做 xxx 之前需要上锁”。这就是条件变量的意义,提供了条件的语义,它必须配合锁来使用。
- 信号量:扩展了条件变量,维护条件从“资源是否被占用”变为了“是否有资源可用”。
乐观锁
乐观锁并不是锁的实现,而是一种技巧,比如一个场景就是在线文档多人协同编辑。考虑到网络延迟因素,我们没有办法让用户本地的锁和服务器上的锁完全同步。但我们可以假定用户都可以编辑,将所有的冲突处理放在定期的轮询上,比如一秒检查一次,进行验证,根据时间戳判断是否存在写冲突,如果存在可以后者覆盖前者,或者进行一些特殊的提示。
总的来说,在业务操作过程中无法和数据库保持同步,就可以假定用户本地不存在锁机制,把所有的冲突放在后端处理。
搜索框输入内容并回车,发生了什么?
标准回答:
这个过程实际上涉及到浏览器的原理。一个浏览器的典型组成,包括一个主进程和若干子进程。子进程主要的有渲染进程、GPU进程。而对于主进程,它分为一些线程,包括主 UI线程、网络线程等。
导航过程
当我们输入URL的时候,这串字符串被UI线程进行分析并分析出它是否是一个URI,亦或者普通的关键字。如果是普通的关键字,则把它拼接和处理成URL。这个时候,这串字符串就指向了一个合法的目标。然后网络线程就会去查看这串URI是否曾经被service worker记录,如果记录,它将把本次请求直接移交给它处理,否则它就会尝试目标URI建立连接,这个连接有可能是本地连接,也有可能是一串URL,典型的URL,会发送HTTP请求,建立TCP三次握手、并进行文件传输和存储,将获取的数据交给渲染进程,再比如如果获取的不是HTML而是一个压缩文件,响应数据将会交给下载管理器来处理。
渲染进程是按标签页分配的,这意味着,每个标签页都会新开一个渲染进程。这个创建新进程的过程,就发生在网络线程请求数据的过程中,被提前开启,来节约时间。渲染进程被确定下来和初始化,这会触发一个叫做提交导航的事件。该渲染进程去触发提交导航给UI线程,得到一个IPC信号量,浏览器进程收到信号后会进行BOM属性的更新,具体来讲安全锁标识、history、navigator 的更新。到这里,导航过程结束,页面加载阶段开始了。
加载阶段
这个过程就是渲染进程是叙述如何解析 html、css、js 代码的了。渲染进程也分为若干线程,绝大部分的这些代码,都是在主线程被处理的,主要的子线程包括合成线程、光栅线程。
在 html 解析过程中,浏览器会先运行“预加载扫描”,它会解析出 html 中需要再次发送网络请求的资源,比如遇到 script 标签,那么它就会通知网络线程去获取这些资源。这是因为如果遇到 js 脚本代码,他们将不得不顺序的加载 js,这会阻塞 html 的解析,这种特性可以通过 defer 和 async 来改变。
随后,DOM树被解析出来,这个过程所有的 css 也被收集并解析,渲染进程依次的根据 css 样式信息 形成了样式树,根据 css 布局信息 形成布局树,并进行页面分层优化(这个过程主要是为了降低流水线更新成本),形成层次树。随后,层次树被移交给合成线程,它将生成绘制记录,并把最终的结果发送给光栅线程,光栅线程光栅化每个图块并丢给GPU,最后GPU进程将处理并把整个页面显示在屏幕上。整个加载阶段结束,对应的事件 onLoad 被触发。