进程与线程的概念(重点)
- 从本质上说,进程和线程都是 CPU 工作时间片的一个描述。
进程
- 描述了 CPU 在运行指令及加载和保存上下文所需的时间,放在应用上来说就代表了一个程序。
- 一个进程就是一个程序的运行实例。 启动一个程序的时候,操作系统会为该程序创建一块内存,用来存放代码、运行中的数据和一个执行任务的主线程,我们把这样的一个运行环境叫进程。
- 进程运行在各自的虚拟地址空间上。虚拟内存主要用于解决用户程序对内存空间的无限需求和有限物理内存之间的矛盾。从操作系统实现角度来看,虚拟内存依赖于页表、页面置换算法以及交换文件(或交换分区)等机制;从处理器角度看,虚拟内存表现为虚拟地址空间以及硬件支持的地址转换(MMU)机制。
- 如果程序很多时,内存可能会不够,操作系统为每个进程提供一套独立的虚拟地址空间,从而使得同一块物理内存在不同的进程中可以对应到不同或相同的虚拟地址,变相地增加了程序可以使用的内存。
线程
- 是进程中的更小单位,描述了执行一段指令所需的时间。
核心区别
- 进程是资源分配的最小单位,线程是 CPU 调度的最小单位。
进程和线程之间的关系
- 进程中的任意一线程执行出错,都会导致整个进程的崩溃。
- 线程之间共享进程中的数据。
- 当一个进程关闭之后,操作系统会回收进程所占用的内存;当一个进程退出时,操作系统会回收该进程所申请的所有资源;即使其中任意线程因为操作不当导致内存泄漏,当进程退出时,这些内存也会被正确回收。
- 进程之间的内容相互隔离。
进程隔离
- 进程隔离就是为了使操作系统中的进程互不干扰,每一个进程只能访问自己占有的数据,也就避免出现进程 A 写入数据到进程 B 的情况。
- 正是因为进程之间的数据是严格隔离的,所以一个进程如果崩溃了,或者挂起了,是不会影响到其他进程的。
- 如果进程之间需要进行数据的通信,就需要使用用于进程间通信的机制了。
Chrome 浏览器的架构
Chrome 采用多进程架构,最新的版本中主要包含以下几种进程:1 个浏览器进程、1 个 GPU 进程、1 个网络进程、多个渲染进程、多个插件进程(如扩展进程、PPAPI 插件进程等)。
各进程功能
- 浏览器进程:负责管理浏览器界面(包括地址栏、书签栏、前进/后退按钮)、用户交互、调度与协调其他进程,以及处理存储(如 Cookie、LocalStorage)等全局性工作。
- GPU 进程:最初用于实现 3D CSS 效果,后来随着网页和浏览器界面普遍采用 GPU 进行硬件加速绘制,GPU 进程成为了处理图形相关任务的核心进程。
- 网络进程:专门负责网络资源的加载与请求管理。在早期架构中,网络功能作为模块运行在浏览器进程内,后为提升稳定性和安全性,被独立为一个单独的进程。
- 渲染进程:核心任务是将 HTML、CSS 和 JavaScript 转换为可交互的网页。Blink 排版引擎和 V8 JavaScript 引擎均运行于此进程中。为了安全考虑,渲染进程通常运行在沙箱模式下,以限制其对系统资源的直接访问。
- 现代 Chrome 默认启用了站点隔离(Site Isolation),通常是为每个不同站点的页面(而非简单地为每个标签页)创建独立的渲染进程。这增强了安全性,但也增加了内存占用。
- 插件进程:用于运行各类插件(如 PPAPI 规范的 Flash 插件或部分浏览器扩展的后台进程)。由于插件代码稳定性较差,通过独立进程运行可以确保插件崩溃时,不会影响浏览器主界面或当前打开的页面。
页面进程数
- 打开一个最简单的网页(如一个静态 HTML 页面,无跨域 iframe、无扩展介入),最少需要 4 个核心进程:1 个浏览器进程、1 个网络进程、1 个 GPU 进程以及 1 个渲染进程。
- 如果页面中包含跨站 iframe、安装了扩展或存在插件,Chrome 会启动额外的渲染进程或插件进程。
多进程模型的问题
多进程架构虽然在稳定性(进程间相互隔离,单个页面/插件崩溃不影响整体)、流畅性(充分利用多核 CPU)和安全性(沙箱机制)方面带来了显著优势,但也引入了以下问题:
- 更高的资源占用:每个进程(尤其是渲染进程)通常需要加载公共基础设施的副本(如 V8 引擎、Blink 核心库),导致整体内存占用较高。此外,站点隔离策略进一步增加了进程数量,使内存开销更为明显。
- 架构复杂性与维护成本:多进程之间的通信(IPC)、状态同步以及资源协调使得架构变得极为复杂。随着技术的发展,这种相对“重量级”的架构在应对新兴需求(如极致的内存节省、与操作系统的深度整合)时,也面临着扩展和维护上的挑战。
进程和线程的区别(重点)
- 进程可以看做独立应用,线程不能。
- 资源方面:进程是 CPU 资源分配的最小单位(是能拥有资源和独立运行的最小单位),线程是 CPU 调度的最小单位(线程是建立在进程的基础上的一个程序运行单位,一个进程中可以有多个线程)。
- 通信方面:线程间可以直接共享同一进程中的资源,而进程通信需要借助进程间通信机制。
- 调度方面:进程切换比线程切换的开销要大。线程是 CPU 调度的基本单位,线程的切换不会引起进程切换,但某个进程中的线程切换到另一个进程中的线程时,会引起进程切换。
- 系统开销方面:由于创建或撤销进程时,系统都要为之分配或回收资源,如内存、I/O 等,其开销远大于创建或撤销线程时的开销。同理,在进行进程切换时,涉及当前执行进程 CPU 环境还有各种各样状态的保存及新调度进程状态的设置,而线程切换时只需保存和设置少量寄存器内容,开销较小。
进程之间的通信方式(重点)
管道通信
- 核心:单向数据流,父子关系专用。
- 管道是一种最基本的进程间通信机制。
- 管道是操作系统在内核中开辟的一段缓冲区,进程1可以将需要交互的数据拷贝到这段缓冲区,进程2就可以读取这些数据。
管道的特点
- 单个匿名管道只能进行单向通信
- 匿名管道只能用于具有血缘关系的进程之间通信
- 匿名管道不依赖文件系统,仅存在于内存中
- 匿名管道的生命周期由内核管理,当所有相关文件描述符关闭后被释放
- 面向字节流的服务
- 管道在内核层面通过阻塞读写机制提供同步能力
消息队列通信
- 核心:发送消息到队列,接收方按需取用。
- 消息队列就是一个消息的列表。用户可以在消息队列中添加消息、读取消息等。
- 消息队列提供了一种从一个进程向另一个进程发送一个数据块的方法。 每个数据块都被认为含有一个类型,接收进程可以独立地接收含有不同类型的数据结构。
- 可以通过发送消息来避免命名管道的同步和阻塞问题。但是消息队列与命名管道一样,每个数据块都有一个最大长度的限制。
- 这种通信方式的缺点是可能会收到数据块最大长度的限制约束等。如果频繁地发生进程间的通信行为,那么进程需要频繁地读取队列中的数据到内存,相当于间接地从一个进程拷贝到另一个进程,这需要花费时间。
信号量通信
- 核心:控制多个进程对共享资源的访问权限。
- 共享内存最大的问题就是多进程竞争内存的问题,就像类似于线程安全问题。我们可以使用信号量来解决这个问题。
- 信号量的本质就是一个计数器,用来实现进程之间的互斥与同步。
- 例如信号量的初始值是 1,然后 a 进程来访问内存1的时候,我们就把信号量的值设为 0,然后进程 b 也要来访问内存1的时候,看到信号量的值为 0 就知道已经有进程在访问内存1了,这个时候进程 b 就会访问不了内存1。所以说,信号量也是进程之间的一种通信方式。
信号通信
- 核心:操作系统通知进程发生了某个事件。
- 信号(Signals)是 Unix 系统中使用的最古老的进程间通信的方法之一。
- 操作系统通过信号来通知进程系统中发生了某种预先规定好的事件(一组事件中的一个),它也是用户进程之间通信和同步的一种原始机制。
共享内存通信
- 核心:多个进程访问同一块内存区域。
- 共享内存就是映射一段能被其他进程所访问的内存,这段共享内存由一个进程创建,但多个进程都可以访问(使多个进程可以访问同一块内存空间)。
- 共享内存是最快的 IPC(Inter-Process Communication,进程间通信)方式,它是针对其他进程间通信方式运行效率低而专门设计的。它往往与其他通信机制如信号量配合使用,来实现进程间的同步和通信。
套接字通信
- 核心:跨网络、跨机器的通信。
- 上面我们说的共享内存、管道、信号量、消息队列,他们都是多个进程在一台主机之间的通信,那两个相隔几千里的进程能够进行通信吗?答是必须的,这个时候 Socket 这家伙就派上用场了,例如我们平时通过浏览器发起一个 http 请求,然后服务器给你返回对应的数据,这种就是采用 Socket 的通信方式了。
前端开发中真正用到的部分场景
-
场景1——Web Workers 多线程通信
- 消息队列(postMessage)、共享内存(SharedArrayBuffer)、信号量(Atomics API)、管道(MessageChannel)
-
场景2——跨标签页通信
- localStorage + storage 事件:共享内存(localStorage)、信号通信(storage 事件)
- Broadcast Channel API:消息队列
- SharedWorker:共享内存、消息队列(postMessage)
- Service Worker:消息队列
-
场景3——iframe 父子通信
- window.postMessage + message 事件:消息队列、跨域套接字(postMessage)
- 直接访问 iframe 元素或window变量:共享内存(有同源限制)
死锁产生的原因,如何解决死锁的问题?(重点)
死锁定义
- 死锁:是指多个进程在运行过程中因争夺资源而造成的一种僵局,当进程处于这种僵持状态时,若无外力作用,它们都将无法再向前推进。
系统中的资源分类
- 可剥夺资源:是指某进程在获得这类资源后,该资源可以再被其他进程或系统剥夺,CPU 和主存均属于可剥夺性资源。
- 不可剥夺资源:当系统把这类资源分配给某进程后,再不能强行收回,只能在进程用完后自行释放,如磁带机、打印机等。
产生死锁的原因
-
竞争资源
- 产生死锁中的竞争资源之一,指的是竞争不可剥夺资源。例如,系统中只有一台打印机,可供进程 P1 使用,假定 P1 已占用了打印机,若 P2 继续要求打印机打印将阻塞。
- 产生死锁中的竞争资源中另外一种资源,指的是竞争临时资源(临时资源包括硬件中断、信号、消息、缓冲区内的消息等),通常消息通信顺序进行不当,则会产生死锁。
-
进程间推进顺序非法
- 若 P1 保持了资源 R1,P2 保持了资源 R2,系统处于不安全状态,因为这两个进程再向前推进,便可能发生死锁。
- 例如,当 P1 运行到 P1:Request(R2)时,将因 R2 已被 P2 占用而阻塞;当 P2 运行到 P2:Request(R1)时,也将因 R1 已被 P1 占用而阻塞,于是发生进程死锁。
产生死锁的必要条件
- 互斥条件:进程要求对所分配的资源进行排它性控制,即在一段时间内某资源仅为一进程所占用。
- 请求和保持条件:当进程因请求资源而阻塞时,对已获得的资源保持不放。
- 不剥夺条件:进程已获得的资源在未使用完之前,不能剥夺,只能在使用完时由自己释放。
- 环路等待条件:在发生死锁时,必然存在一个“进程——资源”的环形链。
预防死锁的方法
- 资源一次性分配:一次性分配所有资源,这样就不会再有请求了(破坏请求条件)。
- 只要有一个资源得不到分配,也不给这个进程分配其他任何资源(破坏请保持条件)。
- 可剥夺资源:即当某进程获得了部分资源,但得不到其它资源,则释放已占有的资源(破坏不可剥夺条件)。
- 资源有序分配法:系统给每类资源赋予一个编号,每一个进程按编号递增的顺序请求资源,释放则相反(破坏环路等待条件)。
如何实现浏览器内多个标签页之间的通信?(重点)
通信原理
- 实现多个标签页之间的通信,本质上都是通过中介者模式来实现的。
- 因为标签页之间没有办法直接通信,因此我们可以找一个中介者,让标签页和中介者进行通信,然后让这个中介者来进行消息的转发。
通信方法
-
使用 WebSocket 协议:因为 WebSocket 协议可以实现服务器推送,所以服务器就可以用来当做这个中介者。标签页通过向服务器发送数据,然后由服务器向其他标签页推送转发。
-
使用 SharedWorker 的方式:SharedWorker 会在页面存在的生命周期内创建一个唯一的线程,并且开启多个页面也只会使用同一个线程。这个时候共享线程就可以充当中介者的角色。标签页间通过共享一个线程,然后通过这个共享的线程来实现数据的交换。
-
使用 localStorage 的方式:我们可以在一个标签页对 localStorage 的变化事件进行监听,然后当另一个标签页修改数据的时候,我们就可以通过这个监听事件来获取到数据。这个时候 localStorage 对象就是充当的中介者的角色。
// 添加事件监听器
window.addEventListener('storage', handleStorageChange);
function handleStorageChange(event) {
try {
console.log('localStorage 发生变化:');
console.log('键:', event.key);
console.log('旧值:', event.oldValue);
console.log('新值:', event.newValue);
console.log('发生变化的URL:', event.url);
// 示例:根据变化执行相应操作
if (event.key === 'theme' && event.newValue) {
document.body.setAttribute('data-theme', event.newValue);
}
} catch (error) {
console.error('处理storage事件时出错:', error);
}
}
// 清理函数(在组件卸载时调用)
function cleanup() {
window.removeEventListener('storage', handleStorageChange);
}
注意限制
- 只能监听其他页面对 localStorage 的修改
- 当前页面的修改不会触发这个事件
- 需要同一域名下的页面
- 使用 postMessage 方法:如果我们能够获得对应标签页的引用,就可以使用 postMessage 方法,进行通信。
// 父窗口
let child;
document.getElementById('open').onclick = () => {
child = window.open('child.html');
};
document.getElementById('send').onclick = () => {
if (child && !child.closed) {
child.postMessage(document.getElementById('msg').value, '*');
}
};
window.onmessage = (e) => {
document.getElementById('output').innerHTML += `<p>收到: ${e.data}</p>`;
};
// 子窗口
document.getElementById('send').onclick = () => {
if (window.opener) {
window.opener.postMessage(document.getElementById('msg').value, '*');
}
};
window.onmessage = (e) => {
document.getElementById('output').innerHTML += `<p>收到: ${e.data}</p>`;
};
浏览器渲染进程的线程有哪些?
浏览器的渲染进程的线程总共有五种:
1. GUI渲染线程
- 负责渲染浏览器页面,解析 HTML、CSS,构建 DOM 树、构建 CSSOM 树、构建渲染树和绘制页面。
- 当界面需要重绘或由于某种操作引发回流时,该线程就会执行。
- 注意,GUI 渲染线程和 JS 引擎线程是互斥的,当 JS 引擎执行时 GUI 线程会被挂起,GUI 更新会被保存在一个队列中等到 JS 引擎空闲时立即被执行。
2. JS 引擎线程
- JS 引擎线程也称为 JS 内核,负责处理 Javascript 脚本程序,解析 Javascript 脚本,运行代码。
- JS 引擎线程一直等待着任务队列中任务的到来,然后加以处理,一个 Tab 页中无论什么时候都只有一个 JS 引擎线程在运行 JS 程序;
- 注意,GUI 渲染线程与 JS 引擎线程的互斥关系,所以如果 JS 执行的时间过长,会造成页面的渲染不连贯,导致页面渲染加载阻塞。
3. 事件触发线程
- 事件触发线程属于浏览器而不是JS引擎,用来控制事件循环。
- 当 JS 引擎执行代码块如 setTimeOut 时(也可是来自浏览器内核的其他线程,如鼠标点击、AJAX 异步请求等),会将对应任务添加到事件触发线程中;当对应的事件符合触发条件被触发时,该线程会把事件添加到待处理队列的队尾,等待 JS 引擎的处理。
- 注意,由于 JS 的单线程关系,所以这些待处理队列中的事件都得排队等待 JS 引擎处理(当 JS 引擎空闲时才会去执行)。
4. 定时器触发线程
- 定时器触发线程即 setInterval 与 setTimeout 所在线程。
- 浏览器定时计数器并不是由 JS 引擎计数的,因为 JS 引擎是单线程的,如果处于阻塞线程状态就会影响记计时的准确性;因此使用单独线程来计时并触发定时器,计时完毕后,添加到事件队列中,等待 JS 引擎空闲后执行,所以定时器中的任务在设定的时间点不一定能够准时执行,定时器只是在指定时间点将任务添加到事件队列中。
- 注意,W3C 在 HTML 标准中规定,定时器的定时时间不能小于4ms,如果是小于4ms,则默认为4ms。
5. 异步 http 请求线程
- XMLHttpRequest 连接后通过浏览器新开一个线程请求。
- 检测到状态变更时,如果设置有回调函数,异步线程就产生状态变更事件,将回调函数放入事件队列中,等待 JS 引擎空闲后执行。
僵尸进程和孤儿进程是什么?
- 僵尸进程:子进程比父进程先结束,而父进程又没有释放子进程占用的资源,那么子进程的进程描述符仍然保存在系统中,这种进程称之为僵尸进程。
- 孤儿进程:父进程退出了,而它的一个或多个进程还在运行,那这些子进程都会成为孤儿进程。孤儿进程将被 init 进程(进程号为1)所收养,并由 init 进程对它们完成状态收集工作。
对 Service Worker 的理解
- Service Worker 是运行在浏览器独立进程(如 Chromium 中的渲染进程)中的后台脚本,其 JavaScript 代码在专用线程中执行,与页面主线程隔离,主要用于实现离线缓存、推送通知、后台同步等高级 Web 功能。
- 使用 Service Worker的话,传输协议必须为 HTTPS。因为 Service Worker 中涉及到请求拦截,所以必须使用 HTTPS 协议来保障安全。
Service Worker 实现缓存功能的步骤
- 首先需要先注册 Service Worker。
- 然后监听到 install 事件以后就可以缓存需要的文件。
- 在下次用户访问的时候就可以通过拦截请求的方式查询是否存在缓存,存在缓存的话就可以直接读取缓存文件,否则就去请求数据。
// index.js
if (navigator.serviceWorker) {
navigator.serviceWorker
.register('sw.js')
.then(function(registration) {
console.log('service worker 注册成功')
})
.catch(function(err) {
console.log('servcie worker 注册失败')
})
}
// sw.js
const CACHE_VERSION = 'v1';
const CACHE_NAME = `my-cache-${CACHE_VERSION}`;
const ASSETS_TO_CACHE = [
'./index.html',
'./index.js'
];
// 安装事件:缓存静态资源
self.addEventListener('install', e => {
e.waitUntil(
caches.open(CACHE_NAME)
.then(cache => {
console.log('缓存已打开');
return cache.addAll(ASSETS_TO_CACHE);
})
.then(() => self.skipWaiting()) // 强制新SW立即激活
);
});
// 激活事件:清理旧缓存
self.addEventListener('activate', e => {
e.waitUntil(
caches.keys().then(cacheNames => {
return Promise.all(
cacheNames.map(cacheName => {
if (cacheName !== CACHE_NAME) {
console.log('清理旧缓存:', cacheName);
return caches.delete(cacheName);
}
})
);
}).then(() => self.clients.claim()) // 立即控制所有客户端
);
});
// fetch事件:实现缓存优先策略
self.addEventListener('fetch', e => {
e.respondWith(
caches.match(e.request)
.then(response => {
// 如果缓存命中,直接返回缓存
if (response) {
return response;
}
// 缓存未命中,发起网络请求
return fetch(e.request)
.then(networkResponse => {
// 检查响应是否有效
if (!networkResponse || networkResponse.status !== 200 || networkResponse.type !== 'basic') {
return networkResponse;
}
// 克隆响应(因为响应流只能使用一次)
const responseToCache = networkResponse.clone();
// 将响应添加到缓存
caches.open(CACHE_NAME)
.then(cache => {
cache.put(e.request, responseToCache);
});
return networkResponse;
})
.catch(error => {
console.error('网络请求失败:', error);
// 可以返回离线页面或默认响应
});
})
);
});
- 在开发者工具中的 Application 标签里,通过 Application -> Service Workers 检查是否启动,通过 Cache -> Cache Storage 检查目标文件是否已缓存。
总结
本文详细介绍了进程与线程的核心概念、区别、通信方式,以及浏览器中的进程线程模型。主要内容包括:
-
进程与线程的本质:都是CPU工作时间片的描述,进程是资源分配的最小单位,线程是CPU调度的最小单位。
-
Chrome浏览器架构:采用多进程模型,包括浏览器主进程、GPU进程、网络进程、渲染进程和插件进程,提升了稳定性和安全性。
-
进程间通信方式:包括管道、消息队列、信号量、信号、共享内存和套接字等,前端开发中常用Web Workers、localStorage、Broadcast Channel等方式。
-
死锁问题:分析了死锁产生的原因和必要条件,并介绍了预防死锁的四种方法。
-
浏览器标签页通信:通过中介者模式实现,包括WebSocket、SharedWorker、localStorage和postMessage等方法。
-
浏览器渲染线程:包括GUI渲染线程、JS引擎线程、事件触发线程、定时器触发线程和异步HTTP请求线程,解释了它们的工作原理和相互关系。
-
特殊进程状态:介绍了僵尸进程和孤儿进程的概念。
-
Service Worker:解释了Service Worker的工作原理和缓存实现步骤。
通过本文的学习,读者可以深入理解进程与线程的核心概念,以及它们在浏览器和前端开发中的应用场景,为构建高性能、可靠的前端应用打下基础。